diff --git a/core/src/main/java/org/keycloak/representations/provider/ScriptProviderDescriptor.java b/core/src/main/java/org/keycloak/representations/provider/ScriptProviderDescriptor.java index fda25865ff..befac86487 100644 --- a/core/src/main/java/org/keycloak/representations/provider/ScriptProviderDescriptor.java +++ b/core/src/main/java/org/keycloak/representations/provider/ScriptProviderDescriptor.java @@ -31,6 +31,8 @@ public class ScriptProviderDescriptor { public static final String POLICIES = "policies"; public static final String MAPPERS = "mappers"; + public static final String SAML_MAPPERS = "saml-mappers"; + private Map> providers = new HashMap<>(); @JsonUnwrapped @@ -54,6 +56,11 @@ public class ScriptProviderDescriptor { providers.put(MAPPERS, metadata); } + @JsonSetter(SAML_MAPPERS) + public void setSAMLMappers(List metadata) { + providers.put(SAML_MAPPERS, metadata); + } + public void addAuthenticator(String name, String fileName) { addProvider(AUTHENTICATORS, name, fileName, null); } @@ -76,4 +83,8 @@ public class ScriptProviderDescriptor { public void addMapper(String name, String fileName) { addProvider(MAPPERS, name, fileName, null); } + + public void addSAMLMapper(String name, String fileName) { + addProvider(SAML_MAPPERS, name, fileName, null); + } } diff --git a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java index c7e5527c5e..eb0bc70147 100644 --- a/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java +++ b/quarkus/deployment/src/main/java/org/keycloak/quarkus/deployment/KeycloakProcessor.java @@ -30,6 +30,7 @@ import static org.keycloak.representations.provider.ScriptProviderDescriptor.MAP import static org.keycloak.representations.provider.ScriptProviderDescriptor.POLICIES; import static org.keycloak.quarkus.runtime.Environment.getProviderFiles; import static org.keycloak.theme.ClasspathThemeProviderFactory.KEYCLOAK_THEMES_JSON; +import static org.keycloak.representations.provider.ScriptProviderDescriptor.SAML_MAPPERS; import javax.persistence.Entity; import javax.persistence.spi.PersistenceUnitTransactionType; @@ -92,6 +93,7 @@ import org.keycloak.config.StorageOptions; import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.connections.jpa.JpaConnectionSpi; import org.keycloak.models.map.storage.jpa.JpaMapStorageProviderFactory; +import org.keycloak.protocol.saml.mappers.DeployedScriptSAMLProtocolMapper; import org.keycloak.quarkus.runtime.QuarkusProfile; import org.keycloak.quarkus.runtime.configuration.PersistedConfigSource; import org.keycloak.quarkus.runtime.configuration.QuarkusPropertiesConfigSource; @@ -174,6 +176,7 @@ class KeycloakProcessor { DEPLOYEABLE_SCRIPT_PROVIDERS.put(AUTHENTICATORS, KeycloakProcessor::registerScriptAuthenticator); DEPLOYEABLE_SCRIPT_PROVIDERS.put(POLICIES, KeycloakProcessor::registerScriptPolicy); DEPLOYEABLE_SCRIPT_PROVIDERS.put(MAPPERS, KeycloakProcessor::registerScriptMapper); + DEPLOYEABLE_SCRIPT_PROVIDERS.put(SAML_MAPPERS, KeycloakProcessor::registerSAMLScriptMapper); } private static ProviderFactory registerScriptAuthenticator(ScriptProviderMetadata metadata) { @@ -188,6 +191,10 @@ class KeycloakProcessor { return new DeployedScriptOIDCProtocolMapper(metadata); } + private static ProviderFactory registerSAMLScriptMapper(ScriptProviderMetadata metadata) { + return new DeployedScriptSAMLProtocolMapper(metadata); + } + @BuildStep FeatureBuildItem getFeature() { return new FeatureBuildItem("keycloak"); @@ -679,7 +686,7 @@ class KeycloakProcessor { } private boolean isScriptForSpi(Spi spi, String type) { - if (spi instanceof ProtocolMapperSpi && MAPPERS.equals(type)) { + if (spi instanceof ProtocolMapperSpi && (MAPPERS.equals(type) || SAML_MAPPERS.equals(type))) { return true; } else if (spi instanceof PolicySpi && POLICIES.equals(type)) { return true; diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/DeployedScriptSAMLProtocolMapper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/DeployedScriptSAMLProtocolMapper.java new file mode 100644 index 0000000000..60af31cb25 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/DeployedScriptSAMLProtocolMapper.java @@ -0,0 +1,59 @@ +package org.keycloak.protocol.saml.mappers; + +import java.util.List; +import java.util.stream.Collectors; + +import org.keycloak.common.Profile; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.provider.ScriptProviderMetadata; + +/** + * @author Marek Posolda + */ +public class DeployedScriptSAMLProtocolMapper extends ScriptBasedMapper { + + protected ScriptProviderMetadata metadata; + + public DeployedScriptSAMLProtocolMapper(ScriptProviderMetadata metadata) { + this.metadata = metadata; + } + + public DeployedScriptSAMLProtocolMapper() { + // for reflection + } + + @Override + public String getId() { + return metadata.getId(); + } + + @Override + public String getDisplayType() { + return metadata.getName(); + } + + @Override + public String getHelpText() { + return metadata.getDescription(); + } + + @Override + protected String getScriptCode(ProtocolMapperModel mapperModel) { + return metadata.getCode(); + } + + public List getConfigProperties() { + return super.getConfigProperties().stream() + .filter(providerConfigProperty -> !ProviderConfigProperty.SCRIPT_TYPE.equals(providerConfigProperty.getName())) // filter "script" property + .collect(Collectors.toList()); + } + + public void setMetadata(ScriptProviderMetadata metadata) { + this.metadata = metadata; + } + + public ScriptProviderMetadata getMetadata() { + return metadata; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/saml/mappers/ScriptBasedMapper.java b/services/src/main/java/org/keycloak/protocol/saml/mappers/ScriptBasedMapper.java index 624731245e..5e0b006857 100644 --- a/services/src/main/java/org/keycloak/protocol/saml/mappers/ScriptBasedMapper.java +++ b/services/src/main/java/org/keycloak/protocol/saml/mappers/ScriptBasedMapper.java @@ -1,10 +1,12 @@ package org.keycloak.protocol.saml.mappers; import org.jboss.logging.Logger; +import org.keycloak.common.Profile; import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; import org.keycloak.dom.saml.v2.assertion.AttributeType; import org.keycloak.models.*; import org.keycloak.protocol.ProtocolMapperConfigException; +import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.scripting.EvaluatableScriptAdapter; import org.keycloak.scripting.ScriptCompilationException; @@ -20,7 +22,7 @@ import java.util.*; * * @author Alistair Doswald */ -public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAMLAttributeStatementMapper { +public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAMLAttributeStatementMapper, EnvironmentDependentProviderFactory { private static final List configProperties = new ArrayList<>(); public static final String PROVIDER_ID = "saml-javascript-mapper"; @@ -92,6 +94,11 @@ public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAM return "Evaluates a JavaScript function to produce an attribute value based on context information."; } + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.SCRIPTS); + } + /** * This method attaches one or many attributes to the passed attribute statement. * To obtain the attribute values, it executes the mapper's script and returns attaches the returned value to the @@ -110,7 +117,7 @@ public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAM KeycloakSession session, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) { UserModel user = userSession.getUser(); - String scriptSource = mappingModel.getConfig().get(ProviderConfigProperty.SCRIPT_TYPE); + String scriptSource = getScriptCode(mappingModel); RealmModel realm = userSession.getRealm(); String single = mappingModel.getConfig().get(SINGLE_VALUE_ATTRIBUTE); @@ -158,7 +165,7 @@ public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAM @Override public void validateConfig(KeycloakSession session, RealmModel realm, ProtocolMapperContainerModel client, ProtocolMapperModel mapperModel) throws ProtocolMapperConfigException { - String scriptCode = mapperModel.getConfig().get(ProviderConfigProperty.SCRIPT_TYPE); + String scriptCode = getScriptCode(mapperModel); if (scriptCode == null) { return; } @@ -173,6 +180,10 @@ public class ScriptBasedMapper extends AbstractSAMLProtocolMapper implements SAM } } + protected String getScriptCode(ProtocolMapperModel mappingModel) { + return mappingModel.getConfig().get(ProviderConfigProperty.SCRIPT_TYPE); + } + /** * Creates an protocol mapper model for the this script based mapper. This mapper model is meant to be used for * testing, as normally such objects are created in a different manner through the keycloak GUI. diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index e9a1e524a5..31dab442c4 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -36,7 +36,6 @@ org.keycloak.protocol.saml.mappers.UserAttributeStatementMapper org.keycloak.protocol.saml.mappers.UserPropertyAttributeStatementMapper org.keycloak.protocol.saml.mappers.UserSessionNoteStatementMapper org.keycloak.protocol.saml.mappers.GroupMembershipMapper -org.keycloak.protocol.saml.mappers.ScriptBasedMapper org.keycloak.protocol.oidc.mappers.UserClientRoleMappingMapper org.keycloak.protocol.oidc.mappers.UserRealmRoleMappingMapper org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java index f25f229df4..7568a4f4d2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/TestCleanup.java @@ -52,7 +52,7 @@ public class TestCleanup { private final String realmName; private final ConcurrentLinkedDeque genericCleanups = new ConcurrentLinkedDeque<>(); - // Key is kind of entity (eg. "client", "role", "user" etc), Values are all kind of entities of given type to cleanup + // Key is kind of entity (eg. "client", "role", "user" etc), Values are all IDs of entities of given type to cleanup private final ConcurrentMultivaluedHashMap entities = new ConcurrentMultivaluedHashMap<>(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/script/DeployedSAMLScriptMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/script/DeployedSAMLScriptMapperTest.java new file mode 100644 index 0000000000..18c007cfaa --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/script/DeployedSAMLScriptMapperTest.java @@ -0,0 +1,165 @@ +package org.keycloak.testsuite.script; + +import java.io.IOException; +import java.util.Collections; +import java.util.stream.Stream; + +import javax.ws.rs.core.Response; + +import org.jboss.arquillian.container.test.api.Deployer; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.TargetsContainer; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.keycloak.common.Profile; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.mappers.AttributeStatementHelper; +import org.keycloak.protocol.saml.mappers.ScriptBasedMapper; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.provider.ScriptProviderDescriptor; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.arquillian.annotation.EnableFeatures; +import org.keycloak.testsuite.saml.AbstractSamlTest; +import org.keycloak.testsuite.saml.RoleMapperTest; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.ProtocolMappersUpdater; +import org.keycloak.testsuite.util.ContainerAssume; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.SamlClient; +import org.keycloak.testsuite.util.SamlClientBuilder; +import org.keycloak.util.JsonSerialization; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.keycloak.common.Profile.Feature.SCRIPTS; +import static org.keycloak.testsuite.arquillian.DeploymentTargetModifier.AUTH_SERVER_CURRENT; +import static org.keycloak.testsuite.saml.RoleMapperTest.createSamlProtocolMapper; +import static org.keycloak.testsuite.util.SamlStreams.assertionsUnencrypted; +import static org.keycloak.testsuite.util.SamlStreams.attributeStatements; +import static org.keycloak.testsuite.util.SamlStreams.attributesUnecrypted; + +/** + * @author Marek Posolda + */ +public class DeployedSAMLScriptMapperTest extends AbstractSamlTest { + + private static final String SCRIPT_DEPLOYMENT_NAME = "scripts.jar"; + + private ClientAttributeUpdater cau; + private ProtocolMappersUpdater pmu; + + @Deployment(name = SCRIPT_DEPLOYMENT_NAME, managed = false, testable = false) + @TargetsContainer(AUTH_SERVER_CURRENT) + public static JavaArchive deploy() throws IOException { + ScriptProviderDescriptor representation = new ScriptProviderDescriptor(); + + representation.addSAMLMapper("My Mapper", "mapper-a.js"); + + return ShrinkWrap.create(JavaArchive.class, SCRIPT_DEPLOYMENT_NAME) + .addAsManifestResource(new StringAsset(JsonSerialization.writeValueAsPrettyString(representation)), + "keycloak-scripts.json") + .addAsResource("scripts/mapper-example.js", "mapper-a.js"); + } + + @BeforeClass + public static void verifyEnvironment() { + ContainerAssume.assumeNotAuthServerUndertow(); + } + + @ArquillianResource + private Deployer deployer; + + @Before + public void deployScripts() throws Exception { + deployer.deploy(SCRIPT_DEPLOYMENT_NAME); + reconnectAdminClient(); + } + + @Before + public void cleanMappersAndScopes() { + this.cau = ClientAttributeUpdater.forClient(adminClient, REALM_NAME, SAML_CLIENT_ID_EMPLOYEE_2) + .setDefaultClientScopes(Collections.EMPTY_LIST) + .update(); + this.pmu = cau.protocolMappers() + .clear() + .update(); + + getCleanup(REALM_NAME) + .addCleanup(this.cau) + .addCleanup(this.pmu); + } + + @After + public void onAfter() throws Exception { + deployer.undeploy(SCRIPT_DEPLOYMENT_NAME); + reconnectAdminClient(); + } + + @Test + public void testScriptMapperNotAvailableThroughAdminRest() { + assertFalse(adminClient.serverInfo().getInfo().getProtocolMapperTypes().get(SamlProtocol.LOGIN_PROTOCOL).stream() + .anyMatch( + mapper -> ScriptBasedMapper.PROVIDER_ID.equals(mapper.getId()))); + + // Doublecheck not possible to create mapper through admin REST + ProtocolMapperRepresentation mapperRep = createSamlProtocolMapper(ScriptBasedMapper.PROVIDER_ID, + ProviderConfigProperty.SCRIPT_TYPE, "'hello_' + user.username", + AttributeStatementHelper.SAML_ATTRIBUTE_NAMEFORMAT, AttributeStatementHelper.BASIC, + AttributeStatementHelper.SAML_ATTRIBUTE_NAME, "SCRIPT_ATTRIBUTE" + ); + + Response response = pmu.getResource().createMapper(mapperRep); + Assert.assertEquals(404, response.getStatus()); + response.close(); + } + + + @Test + @EnableFeature(value = SCRIPTS, skipRestart = true, executeAsLast = false) + public void testScriptMappingThroughServerDeploy() { + // ScriptBasedMapper still not available even if SCRIPTS feature is enabled + testScriptMapperNotAvailableThroughAdminRest(); + + pmu.add( + createSamlProtocolMapper("script-mapper-a.js", + AttributeStatementHelper.SAML_ATTRIBUTE_NAMEFORMAT, AttributeStatementHelper.BASIC, + AttributeStatementHelper.SAML_ATTRIBUTE_NAME, "SCRIPT_ATTRIBUTE" + ) + ).update(); + + assertLoginSuccessWithAttributeAvailable(); + } + + + private void assertLoginSuccessWithAttributeAvailable() { + SAMLDocumentHolder samlResponse = new SamlClientBuilder() + .authnRequest(getAuthServerSamlEndpoint(REALM_NAME), SAML_CLIENT_ID_EMPLOYEE_2, RoleMapperTest.SAML_ASSERTION_CONSUMER_URL_EMPLOYEE_2, SamlClient.Binding.POST) + .build() + .login().user(bburkeUser).build() + .getSamlResponse(SamlClient.Binding.POST); + + assertThat(samlResponse.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + + Stream assertions = assertionsUnencrypted(samlResponse.getSamlObject()); + Stream attributes = attributesUnecrypted(attributeStatements(assertions)); + String scriptAttrValue = attributes + .filter(attribute -> "SCRIPT_ATTRIBUTE".equals(attribute.getName())) + .map(attribute -> attribute.getAttributeValue().get(0).toString()) + .findFirst().orElseThrow(() -> new AssertionError("Attribute SCRIPT_ATTRIBUTE was not available in SAML assertion")); + + Assert.assertEquals("hello_bburke", scriptAttrValue); + } + +} diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java index 4450b29f20..23153d7b4a 100755 --- a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java +++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java @@ -40,6 +40,7 @@ import org.keycloak.models.RealmModel; import org.keycloak.platform.Platform; import org.keycloak.protocol.ProtocolMapperSpi; import org.keycloak.protocol.oidc.mappers.DeployedScriptOIDCProtocolMapper; +import org.keycloak.protocol.saml.mappers.DeployedScriptSAMLProtocolMapper; import org.keycloak.provider.KeycloakDeploymentInfo; import org.keycloak.provider.ProviderFactory; import org.keycloak.provider.ProviderManager; @@ -601,6 +602,9 @@ public class KeycloakServer { addScriptProvider(info, scriptProviderDescriptor.getProviders().getOrDefault("mappers", Collections.emptyList()), ProtocolMapperSpi.class, DeployedScriptOIDCProtocolMapper::new); + addScriptProvider(info, scriptProviderDescriptor.getProviders().getOrDefault("saml-mappers", Collections.emptyList()), + ProtocolMapperSpi.class, + DeployedScriptSAMLProtocolMapper::new); addScriptProvider(info, scriptProviderDescriptor.getProviders().getOrDefault("policies", Collections.emptyList()), PolicySpi.class, DeployedScriptPolicyFactory::new); diff --git a/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/ScriptProviderDeploymentProcessor.java b/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/ScriptProviderDeploymentProcessor.java index c920c2d1c0..8bb43b1caf 100644 --- a/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/ScriptProviderDeploymentProcessor.java +++ b/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/ScriptProviderDeploymentProcessor.java @@ -19,6 +19,7 @@ package org.keycloak.subsystem.server.extension; import static org.keycloak.representations.provider.ScriptProviderDescriptor.AUTHENTICATORS; import static org.keycloak.representations.provider.ScriptProviderDescriptor.MAPPERS; import static org.keycloak.representations.provider.ScriptProviderDescriptor.POLICIES; +import static org.keycloak.representations.provider.ScriptProviderDescriptor.SAML_MAPPERS; import java.io.IOException; import java.io.InputStream; @@ -39,6 +40,7 @@ import org.keycloak.authorization.policy.provider.js.DeployedScriptPolicyFactory import org.keycloak.common.util.StreamUtil; import org.keycloak.protocol.ProtocolMapperSpi; import org.keycloak.protocol.oidc.mappers.DeployedScriptOIDCProtocolMapper; +import org.keycloak.protocol.saml.mappers.DeployedScriptSAMLProtocolMapper; import org.keycloak.provider.KeycloakDeploymentInfo; import org.keycloak.representations.provider.ScriptProviderDescriptor; import org.keycloak.representations.provider.ScriptProviderMetadata; @@ -63,6 +65,10 @@ final class ScriptProviderDeploymentProcessor { info.addProvider(ProtocolMapperSpi.class, new DeployedScriptOIDCProtocolMapper(metadata)); } + private static void registerSAMLScriptMapper(KeycloakDeploymentInfo info, ScriptProviderMetadata metadata) { + info.addProvider(ProtocolMapperSpi.class, new DeployedScriptSAMLProtocolMapper(metadata)); + } + static void deploy(DeploymentUnit deploymentUnit, KeycloakDeploymentInfo info) { ResourceRoot resourceRoot = deploymentUnit.getAttachment(Attachments.DEPLOYMENT_ROOT); @@ -129,5 +135,6 @@ final class ScriptProviderDeploymentProcessor { PROVIDERS.put(AUTHENTICATORS, ScriptProviderDeploymentProcessor::registerScriptAuthenticator); PROVIDERS.put(POLICIES, ScriptProviderDeploymentProcessor::registerScriptPolicy); PROVIDERS.put(MAPPERS, ScriptProviderDeploymentProcessor::registerScriptMapper); + PROVIDERS.put(SAML_MAPPERS, ScriptProviderDeploymentProcessor::registerSAMLScriptMapper); } }