diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java index 272fcaf165..d882bdd271 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java @@ -186,6 +186,25 @@ public class BrokeredIdentityContext { } } + public Map> getAttributes() { + Map> result = new HashMap<>(); + + for (Map.Entry entry : this.contextData.entrySet()) { + if (entry.getKey().startsWith(Constants.USER_ATTRIBUTES_PREFIX)) { + String attrName = entry.getKey().substring(Constants.USER_ATTRIBUTES_PREFIX.length()); + List asList = (List) getContextData().get(Constants.USER_ATTRIBUTES_PREFIX + attrName); + + if (asList.isEmpty()) { + continue; + } + + result.put(attrName, asList); + } + } + + return result; + } + public String getFirstName() { return firstName; } diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimMapper.java index ef2572aa7d..0eb488bc35 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimMapper.java +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/AbstractClaimMapper.java @@ -24,6 +24,8 @@ import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.provider.AbstractIdentityProviderMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.representations.JsonWebToken; import org.keycloak.util.JsonSerialization; @@ -80,12 +82,24 @@ public abstract class AbstractClaimMapper extends AbstractIdentityProviderMapper } { // search ID Token - JsonWebToken token = (JsonWebToken)context.getContextData().get(KeycloakOIDCIdentityProvider.VALIDATED_ID_TOKEN); - if (token != null) { - Object value = getClaimValue(token, claim); - if (value != null) return value; + Object rawIdToken = context.getContextData().get(KeycloakOIDCIdentityProvider.VALIDATED_ID_TOKEN); + JsonWebToken idToken; + + if (rawIdToken instanceof String) { + try { + idToken = new JWSInput(rawIdToken.toString()).readJsonContent(JsonWebToken.class); + } catch (JWSInputException e) { + return null; + } + } else if (rawIdToken instanceof JsonWebToken) { + idToken = (JsonWebToken) rawIdToken; + } else { + return null; } + Object value = getClaimValue(idToken, claim); + if (value != null) + return value; } { // Search the OIDC UserInfo claim set (if any) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java index 5ac2965d5b..aa458fc4cd 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java @@ -74,6 +74,7 @@ import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_CLIENT; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -607,6 +608,14 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { IdentityProviderMapperSyncModeDelegate.delegateUpdateBrokeredUser(session, realm, user, mapper, context, target); } } + + // make sure user attributes are updated based on attributes set to the context + for (Map.Entry> attr : context.getAttributes().entrySet()) { + if (!UserModel.USERNAME.equalsIgnoreCase(attr.getKey())) { + user.setAttribute(attr.getKey(), attr.getValue()); + } + } + return user; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java new file mode 100644 index 0000000000..a2ad54e77c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.broker; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.admin.client.resource.IdentityProviderResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authorization.model.Policy; +import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.broker.oidc.mappers.UserAttributeMapper; +import org.keycloak.common.Profile; +import org.keycloak.models.ClientModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderMapperSyncMode; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; +import org.keycloak.representations.idm.authorization.DecisionStrategy; +import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.EnableFeatures; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.BasicAuthHelper; +import com.google.common.collect.ImmutableMap; + +public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBaseBrokerTest { + + @Override + protected BrokerConfiguration getBrokerConfiguration() { + return KcOidcBrokerConfiguration.INSTANCE; + } + + @Test + @EnableFeatures({ @EnableFeature(Profile.Feature.TOKEN_EXCHANGE), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) }) + public void testExternalInternalTokenExchange() throws Exception { + RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName()); + ClientsResource clients = providerRealm.clients(); + ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0); + brokerApp.setDirectAccessGrantsEnabled(true); + ClientResource brokerAppResource = providerRealm.clients().get(brokerApp.getId()); + brokerAppResource.update(brokerApp); + brokerAppResource.getProtocolMappers().createMapper(createHardcodedClaim("hard-coded", "hard-coded", "hard-coded", "String", true, true)).close(); + + IdentityProviderMapperRepresentation hardCodedSessionNoteMapper = new IdentityProviderMapperRepresentation(); + hardCodedSessionNoteMapper.setName("hard-coded"); + hardCodedSessionNoteMapper.setIdentityProviderAlias(bc.getIDPAlias()); + hardCodedSessionNoteMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID); + hardCodedSessionNoteMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.INHERIT.toString()) + .put(UserAttributeMapper.USER_ATTRIBUTE, "mapped-from-claim") + .put(UserAttributeMapper.CLAIM, "hard-coded") + .build()); + + RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName()); + IdentityProviderResource identityProviderResource = consumerRealm.identityProviders().get(bc.getIDPAlias()); + identityProviderResource.addMapper(hardCodedSessionNoteMapper).close(); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(bc.providerRealmName(), bc.getUserLogin(), bc.getUserPassword(), null, brokerApp.getClientId(), brokerApp.getSecret()); + assertThat(tokenResponse.getIdToken(), notNullValue()); + + testingClient.server().run(KcOidcBrokerTokenExchangeTest::setupRealm); + + ClientRepresentation client = consumerRealm.clients().findByClientId("test-app").get(0); + + Client httpClient = AdminClientUtil.createResteasyClient(); + + try { + WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(bc.consumerRealmName()) + .path("protocol/openid-connect/token"); + // test user info validation. + try (Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader( + client.getClientId(), client.getSecret())) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, tokenResponse.getIdToken()) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE) + .param(OAuth2Constants.SUBJECT_ISSUER, bc.getIDPAlias()) + .param(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID) + + ))) { + assertThat(response.getStatus(), equalTo(200)); + UserRepresentation user = consumerRealm.users().search(bc.getUserLogin()).get(0); + assertThat(user.getAttributes().get("mapped-from-claim").get(0), equalTo("hard-coded")); + } + } finally { + httpClient.close(); + } + } + + private static void setupRealm(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(BrokerTestConstants.REALM_CONS_NAME); + IdentityProviderModel idp = realm.getIdentityProviderByAlias(IDP_OIDC_ALIAS); + org.junit.Assert.assertNotNull(idp); + + ClientModel client = realm.addClient("test-app"); + client.setClientId("test-app"); + client.setPublicClient(false); + client.setDirectAccessGrantsEnabled(true); + client.setEnabled(true); + client.setSecret("secret"); + client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + client.setFullScopeAllowed(false); + + AdminPermissionManagement management = AdminPermissions.management(session, realm); + management.idps().setPermissionsEnabled(idp, true); + ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation(); + clientRep.setName("toIdp"); + clientRep.addClient(client.getId()); + ResourceServer server = management.realmResourceServer(); + Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep); + management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy); + } +}