From 583fa07bc4b03caa6b61790f8f1935fb9b011abf Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Mon, 31 Aug 2020 16:42:35 +0200 Subject: [PATCH] KEYCLOAK-11029 Support modification of broker username / ID for identity provider linking --- .../org/keycloak/common/util/StackUtil.java | 8 +- .../oidc/mappers/UsernameTemplateMapper.java | 38 ++- .../saml/mappers/UsernameTemplateMapper.java | 61 +++- .../JBossLoggingEventListenerProvider.java | 5 + .../main/module.xml | 2 + .../testsuite/broker/BrokerTestConstants.java | 34 +- .../keycloak/testsuite/updaters/Creator.java | 16 + .../testsuite/util/SamlClientBuilder.java | 24 +- .../saml/ModifySamlResponseStepBuilder.java | 160 ++++++---- .../util/saml/SamlDocumentStepBuilder.java | 22 +- .../federation/ldap/AbstractLDAPTest.java | 10 +- ...SamlIdPInitiatedVaryingLetterCaseTest.java | 299 ++++++++++++++++++ .../testsuite/saml/AbstractSamlTest.java | 7 + .../resources/jboss-deployment-structure.xml | 2 + 14 files changed, 571 insertions(+), 117 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPSamlIdPInitiatedVaryingLetterCaseTest.java diff --git a/common/src/main/java/org/keycloak/common/util/StackUtil.java b/common/src/main/java/org/keycloak/common/util/StackUtil.java index 9adf0192bb..22a74f6aae 100644 --- a/common/src/main/java/org/keycloak/common/util/StackUtil.java +++ b/common/src/main/java/org/keycloak/common/util/StackUtil.java @@ -44,9 +44,7 @@ public class StackUtil { * level, then returns stack trace, else returns empty {@link StringBuilder} */ public static StringBuilder getShortStackTrace(String prefix) { - if (! LOG.isTraceEnabled()) { - return EMPTY; - } + if (! isShortStackTraceEnabled()) return EMPTY; StringBuilder sb = new StringBuilder(); StackTraceElement[] stackTrace = (new Throwable()).getStackTrace(); @@ -62,4 +60,8 @@ public class StackUtil { } return sb; } + + public static boolean isShortStackTraceEnabled() { + return LOG.isTraceEnabled(); + } } diff --git a/services/src/main/java/org/keycloak/broker/oidc/mappers/UsernameTemplateMapper.java b/services/src/main/java/org/keycloak/broker/oidc/mappers/UsernameTemplateMapper.java index 3da249e066..4160c78998 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/mappers/UsernameTemplateMapper.java +++ b/services/src/main/java/org/keycloak/broker/oidc/mappers/UsernameTemplateMapper.java @@ -20,6 +20,7 @@ package org.keycloak.broker.oidc.mappers; import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory; import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.saml.mappers.UsernameTemplateMapper.Target; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderSyncMode; import org.keycloak.models.KeycloakSession; @@ -45,9 +46,15 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static org.keycloak.broker.saml.mappers.UsernameTemplateMapper.TARGET; +import static org.keycloak.broker.saml.mappers.UsernameTemplateMapper.TARGETS; +import static org.keycloak.broker.saml.mappers.UsernameTemplateMapper.TRANSFORMERS; +import static org.keycloak.broker.saml.mappers.UsernameTemplateMapper.getTarget; /** * @author Bill Burke @@ -83,10 +90,20 @@ public class UsernameTemplateMapper extends AbstractClaimMapper { property = new ProviderConfigProperty(); property.setName(TEMPLATE); property.setLabel("Template"); - property.setHelpText("Template to use to format the username to import. Substitutions are enclosed in ${}. For example: '${ALIAS}.${CLAIM.sub}'. ALIAS is the provider alias. CLAIM. references an ID or Access token claim."); + property.setHelpText("Template to use to format the username to import. Substitutions are enclosed in ${}. For example: '${ALIAS}.${CLAIM.sub}'. ALIAS is the provider alias. CLAIM. references an ID or Access token claim. \n" + + "The substitution can be converted to upper or lower case by appending |uppercase or |lowercase to the substituted value, e.g. '${CLAIM.sub | lowercase}"); property.setType(ProviderConfigProperty.STRING_TYPE); property.setDefaultValue("${ALIAS}.${CLAIM.preferred_username}"); configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(TARGET); + property.setLabel("Target"); + property.setHelpText("Destination field for the mapper. LOCAL (default) means that the changes are applied to the username stored in local database upon user import. BROKER_ID and BROKER_USERNAME means that the changes are stored into the ID or username used for federation user lookup, respectively."); + property.setType(ProviderConfigProperty.LIST_TYPE); + property.setOptions(TARGETS); + property.setDefaultValue(Target.LOCAL.toString()); + configProperties.add(property); } public static final String PROVIDER_ID = "oidc-username-idp-mapper"; @@ -129,12 +146,12 @@ public class UsernameTemplateMapper extends AbstractClaimMapper { public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { // preprocessFederatedIdentity gets called anyways, so we only need to set the username if necessary. // However, we don't want to set the username when the email is used as username - if (!realm.isRegistrationEmailAsUsername()) { + if (getTarget(mapperModel.getConfig().get(TARGET)) == Target.LOCAL && !realm.isRegistrationEmailAsUsername()) { user.setUsername(context.getModelUsername()); } } - static Pattern substitution = Pattern.compile("\\$\\{([^}]+)\\}"); + private static final Pattern SUBSTITUTION = Pattern.compile("\\$\\{([^}]+?)(?:\\s*\\|\\s*(\\S+)\\s*)?\\}"); @Override public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { @@ -143,27 +160,30 @@ public class UsernameTemplateMapper extends AbstractClaimMapper { private void setUserNameFromTemplate(IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { String template = mapperModel.getConfig().get(TEMPLATE); - Matcher m = substitution.matcher(template); + Matcher m = SUBSTITUTION.matcher(template); StringBuffer sb = new StringBuffer(); while (m.find()) { String variable = m.group(1); + UnaryOperator transformer = Optional.ofNullable(m.group(2)).map(TRANSFORMERS::get).orElse(UnaryOperator.identity()); + if (variable.equals("ALIAS")) { - m.appendReplacement(sb, context.getIdpConfig().getAlias()); + m.appendReplacement(sb, transformer.apply(context.getIdpConfig().getAlias())); } else if (variable.equals("UUID")) { - m.appendReplacement(sb, KeycloakModelUtils.generateId()); + m.appendReplacement(sb, transformer.apply(KeycloakModelUtils.generateId())); } else if (variable.startsWith("CLAIM.")) { String name = variable.substring("CLAIM.".length()); Object value = AbstractClaimMapper.getClaimValue(context, name); if (value == null) value = ""; - m.appendReplacement(sb, value.toString()); + m.appendReplacement(sb, transformer.apply(value.toString())); } else { m.appendReplacement(sb, m.group(1)); } } m.appendTail(sb); - String username = sb.toString(); - context.setModelUsername(username); + + Target t = getTarget(mapperModel.getConfig().get(TARGET)); + t.set(context, sb.toString()); } @Override diff --git a/services/src/main/java/org/keycloak/broker/saml/mappers/UsernameTemplateMapper.java b/services/src/main/java/org/keycloak/broker/saml/mappers/UsernameTemplateMapper.java index befb917931..762cc86ce3 100755 --- a/services/src/main/java/org/keycloak/broker/saml/mappers/UsernameTemplateMapper.java +++ b/services/src/main/java/org/keycloak/broker/saml/mappers/UsernameTemplateMapper.java @@ -36,9 +36,13 @@ import org.keycloak.provider.ProviderConfigProperty; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,10 +54,20 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { public static final String[] COMPATIBLE_PROVIDERS = {SAMLIdentityProviderFactory.PROVIDER_ID}; - private static final List configProperties = new ArrayList(); - public static final String TEMPLATE = "template"; + public static final String TARGET = "target"; + public enum Target { + LOCAL { public void set(BrokeredIdentityContext context, String value) { context.setModelUsername(value); } }, + BROKER_ID { public void set(BrokeredIdentityContext context, String value) { context.setId(value); } }, + BROKER_USERNAME { public void set(BrokeredIdentityContext context, String value) { context.setUsername(value); } }; + public abstract void set(BrokeredIdentityContext context, String value); + } + public static final List TARGETS = Arrays.asList(Target.LOCAL.toString(), Target.BROKER_ID.toString(), Target.BROKER_USERNAME.toString()); + + public static final Map> TRANSFORMERS = new HashMap<>(); + + private static final List configProperties = new ArrayList(); private static final Set IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values())); static { @@ -61,10 +75,23 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { property = new ProviderConfigProperty(); property.setName(TEMPLATE); property.setLabel("Template"); - property.setHelpText("Template to use to format the username to import. Substitutions are enclosed in ${}. For example: '${ALIAS}.${NAMEID}'. ALIAS is the provider alias. NAMEID is that SAML name id assertion. ATTRIBUTE. references a SAML attribute where name is the attribute name or friendly name."); + property.setHelpText("Template to use to format the username to import. Substitutions are enclosed in ${}. For example: '${ALIAS}.${NAMEID}'. ALIAS is the provider alias. NAMEID is that SAML name id assertion. ATTRIBUTE. references a SAML attribute where name is the attribute name or friendly name. \n" + + "The substitution can be converted to upper or lower case by appending |uppercase or |lowercase to the substituted value, e.g. '${NAMEID | lowercase}"); property.setType(ProviderConfigProperty.STRING_TYPE); property.setDefaultValue("${ALIAS}.${NAMEID}"); configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(TARGET); + property.setLabel("Target"); + property.setHelpText("Destination field for the mapper. LOCAL (default) means that the changes are applied to the username stored in local database upon user import. BROKER_ID and BROKER_USERNAME means that the changes are stored into the ID or username used for federation user lookup, respectively."); + property.setType(ProviderConfigProperty.LIST_TYPE); + property.setOptions(TARGETS); + property.setDefaultValue(Target.LOCAL.toString()); + configProperties.add(property); + + TRANSFORMERS.put("uppercase", String::toUpperCase); + TRANSFORMERS.put("lowercase", String::toLowerCase); } public static final String PROVIDER_ID = "saml-username-idp-mapper"; @@ -107,12 +134,12 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { // preprocessFederatedIdentity gets called anyways, so we only need to set the username if necessary. // However, we don't want to set the username when the email is used as username - if (!realm.isRegistrationEmailAsUsername()) { + if (getTarget(mapperModel.getConfig().get(TARGET)) == Target.LOCAL && !realm.isRegistrationEmailAsUsername()) { user.setUsername(context.getModelUsername()); } } - static Pattern substitution = Pattern.compile("\\$\\{([^}]+)\\}"); + private static final Pattern SUBSTITUTION = Pattern.compile("\\$\\{([^}]+?)(?:\\s*\\|\\s*(\\S+)\\s*)?\\}"); @Override public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { @@ -122,19 +149,21 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { private void setUserNameFromTemplate(IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { AssertionType assertion = (AssertionType)context.getContextData().get(SAMLEndpoint.SAML_ASSERTION); String template = mapperModel.getConfig().get(TEMPLATE); - Matcher m = substitution.matcher(template); + Matcher m = SUBSTITUTION.matcher(template); StringBuffer sb = new StringBuffer(); while (m.find()) { String variable = m.group(1); + UnaryOperator transformer = Optional.ofNullable(m.group(2)).map(TRANSFORMERS::get).orElse(UnaryOperator.identity()); + if (variable.equals("ALIAS")) { - m.appendReplacement(sb, context.getIdpConfig().getAlias()); + m.appendReplacement(sb, transformer.apply(context.getIdpConfig().getAlias())); } else if (variable.equals("UUID")) { - m.appendReplacement(sb, KeycloakModelUtils.generateId()); + m.appendReplacement(sb, transformer.apply(KeycloakModelUtils.generateId())); } else if (variable.equals("NAMEID")) { SubjectType subject = assertion.getSubject(); SubjectType.STSubType subType = subject.getSubType(); NameIDType subjectNameID = (NameIDType) subType.getBaseID(); - m.appendReplacement(sb, subjectNameID.getValue()); + m.appendReplacement(sb, transformer.apply(subjectNameID.getValue())); } else if (variable.startsWith("ATTRIBUTE.")) { String name = variable.substring("ATTRIBUTE.".length()); String value = ""; @@ -150,14 +179,16 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { } } } - m.appendReplacement(sb, value); + m.appendReplacement(sb, transformer.apply(value)); } else { m.appendReplacement(sb, m.group(1)); } } m.appendTail(sb); - context.setModelUsername(sb.toString()); + + Target t = getTarget(mapperModel.getConfig().get(TARGET)); + t.set(context, sb.toString()); } @Override @@ -165,4 +196,12 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { return "Format the username to import."; } + public static Target getTarget(String value) { + try { + return value == null ? Target.LOCAL : Target.valueOf(value); + } catch (IllegalArgumentException ex) { + return Target.LOCAL; + } + } + } diff --git a/services/src/main/java/org/keycloak/events/log/JBossLoggingEventListenerProvider.java b/services/src/main/java/org/keycloak/events/log/JBossLoggingEventListenerProvider.java index 6ff58efcca..1a1a2e4e74 100755 --- a/services/src/main/java/org/keycloak/events/log/JBossLoggingEventListenerProvider.java +++ b/services/src/main/java/org/keycloak/events/log/JBossLoggingEventListenerProvider.java @@ -17,6 +17,7 @@ package org.keycloak.events.log; +import org.keycloak.common.util.StackUtil; import org.jboss.logging.Logger; import org.keycloak.events.Event; import org.keycloak.events.EventListenerProvider; @@ -95,6 +96,10 @@ public class JBossLoggingEventListenerProvider implements EventListenerProvider if(logger.isTraceEnabled()) { setKeycloakContext(sb); + + if (StackUtil.isShortStackTraceEnabled()) { + sb.append(", stackTrace=").append(StackUtil.getShortStackTrace()); + } } logger.log(logger.isTraceEnabled() ? Logger.Level.TRACE : level, sb.toString()); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml index e29f10cbf1..00d0bc8df5 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/org/keycloak/testsuite/integration-arquillian-testsuite-providers/main/module.xml @@ -30,6 +30,8 @@ + + diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/broker/BrokerTestConstants.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/broker/BrokerTestConstants.java index 6bb79fd848..141677fdef 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/broker/BrokerTestConstants.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/broker/BrokerTestConstants.java @@ -1,25 +1,25 @@ package org.keycloak.testsuite.broker; -class BrokerTestConstants { +public class BrokerTestConstants { - final static String REALM_PROV_NAME = "provider"; - final static String REALM_CONS_NAME = "consumer"; + public final static String REALM_PROV_NAME = "provider"; + public final static String REALM_CONS_NAME = "consumer"; - final static String IDP_OIDC_ALIAS = "kc-oidc-idp"; - final static String IDP_OIDC_PROVIDER_ID = "keycloak-oidc"; + public final static String IDP_OIDC_ALIAS = "kc-oidc-idp"; + public final static String IDP_OIDC_PROVIDER_ID = "keycloak-oidc"; - final static String IDP_SAML_ALIAS = "kc-saml-idp"; - final static String IDP_SAML_PROVIDER_ID = "saml"; + public final static String IDP_SAML_ALIAS = "kc-saml-idp"; + public final static String IDP_SAML_PROVIDER_ID = "saml"; - final static String CLIENT_ID = "brokerapp"; - final static String CLIENT_SECRET = "secret"; - final static String VAULT_CLIENT_SECRET = "${vault.oidc_idp}"; + public final static String CLIENT_ID = "brokerapp"; + public final static String CLIENT_SECRET = "secret"; + public final static String VAULT_CLIENT_SECRET = "${vault.oidc_idp}"; - final static String USER_LOGIN = "testuser"; - final static String USER_EMAIL = "user@localhost.com"; - final static String USER_PASSWORD = "password"; + public final static String USER_LOGIN = "testuser"; + public final static String USER_EMAIL = "user@localhost.com"; + public final static String USER_PASSWORD = "password"; - final static String IDP_SAML_SIGN_KEY = "MIICWwIBAAKBgQDVG8a7xGN6ZIkDbeecySygc" + + public final static String IDP_SAML_SIGN_KEY = "MIICWwIBAAKBgQDVG8a7xGN6ZIkDbeecySygc" + "DfsypjUMNPE4QJjis8B316CvsZQ0hcTTLUyiRpHlHZys2k3xEhHBHymFC1AONcvzZzpb4" + "0tAhLHO1qtAnut00khjAdjR3muLVdGkM/zMC7G5s9iIwBVhwOQhy+VsGnCH91EzkjZ4SV" + "Er55KJoyQJQIDAQABAoGADaTtoG/+foOZUiLjRWKL/OmyavK9vjgyFtThNkZY4qHOh0h3" + @@ -33,7 +33,7 @@ class BrokerTestConstants { "kAC5loUGwU5dLaugsGH/a2Q8Ac8bmPglwfCstYDpl8Gp/eimb1eKyvDEELOhyImAv4/uZ" + "V9wN85V0xZXWsw=="; - final static String IDP_SAML_SIGN_CERT = "MIIDdzCCAl+gAwIBAgIEbySuqTANBgkqhkiG" + + public final static String IDP_SAML_SIGN_CERT = "MIIDdzCCAl+gAwIBAgIEbySuqTANBgkqhkiG" + "9w0BAQsFADBsMRAwDgYDVQQGEwdVbmtub3duMRAwDgYDVQQIEwdVbmtub3duMRAwDgYDV" + "QQHEwdVbmtub3duMRAwDgYDVQQKEwdVbmtub3duMRAwDgYDVQQLEwdVbmtub3duMRAwDg" + "YDVQQDEwdVbmtub3duMB4XDTE1MDEyODIyMTYyMFoXDTE3MTAyNDIyMTYyMFowbDEQMA4" + @@ -52,7 +52,7 @@ class BrokerTestConstants { "KAXjMeHfzbiBr+cWz8NYZEtxUEDYDjTpKrYCSMJBXpmgVJCZ00BswbksxJwaGqGMPpUKm" + "CV671pf3m8nq3xyiHMDGuGwtbU+GE8kVx85menmp8+964nin"; - final static String REALM_PRIVATE_KEY = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwg" + + public final static String REALM_PRIVATE_KEY = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwg" + "gSkAgEAAoIBAQCCPyvTTb14vSMkpe/pds2P5Cqxk7bkeFnQiNMS1vyZ+HS2O79fxzp1eA" + "guHnBTs4XTRT7SZJhIT/6utgqZjmDigKV5N7X5ptq8BM/W1qa1cYBRip261pc+tWf3Iyw" + "JYQ9yFI9mUQarmIEl0D7GH16NSZklheaWfbodRVarvX+ML0amNtGYVDft/RftYmgbKKrK" + @@ -77,7 +77,7 @@ class BrokerTestConstants { "spPC1kySiy+Ndr9jNohRZkR7pEjgqA5E8rdzc88LirUN7bY5HFHRWN9KXrs5/o3O1K3GF" + "Cp64N6nvnPEYZ2zSJalcMC2fjSsJg26z8Dg1H+gfTIDUMoGiEAAnJXuqk+WayPU+fZMLn"; - final static String REALM_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgK" + + public final static String REALM_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgK" + "CAQEAgj8r0029eL0jJKXv6XbNj+QqsZO25HhZ0IjTEtb8mfh0tju/X8c6dXgILh5wU7OF0" + "0U+0mSYSE/+rrYKmY5g4oCleTe1+abavATP1tamtXGAUYqdutaXPrVn9yMsCWEPchSPZlE" + "Gq5iBJdA+xh9ejUmZJYXmln26HUVWq71/jC9GpjbRmFQ37f0X7WJoGyiqyttfKkKfUeBmR" + diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/Creator.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/Creator.java index ed672a4c54..5995992bec 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/Creator.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/Creator.java @@ -24,6 +24,8 @@ import org.keycloak.admin.client.resource.ComponentResource; import org.keycloak.admin.client.resource.ComponentsResource; import org.keycloak.admin.client.resource.GroupResource; import org.keycloak.admin.client.resource.GroupsResource; +import org.keycloak.admin.client.resource.IdentityProviderResource; +import org.keycloak.admin.client.resource.IdentityProvidersResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UsersResource; @@ -32,6 +34,7 @@ import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ComponentRepresentation; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import java.util.HashMap; @@ -39,7 +42,9 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import javax.ws.rs.core.Response; +import org.hamcrest.Matchers; import org.jboss.logging.Logger; +import org.junit.Assert; import static org.keycloak.testsuite.admin.ApiUtil.getCreatedId; /** @@ -105,6 +110,17 @@ public class Creator implements AutoCloseable { } } + public static Creator create(RealmResource realmResource, IdentityProviderRepresentation rep) { + final IdentityProvidersResource res = realmResource.identityProviders(); + Assert.assertThat("Identity provider alias must be specified", rep.getAlias(), Matchers.notNullValue()); + try (Response response = res.create(rep)) { + String createdId = getCreatedId(response); + final IdentityProviderResource r = res.get(rep.getAlias()); + LOG.debugf("Created identity provider ID %s", createdId); + return new Creator(createdId, r, r::remove); + } + } + private final String id; private final T resource; private final Runnable closer; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java index 4cdcf2eb08..bc259628c8 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/SamlClientBuilder.java @@ -16,6 +16,7 @@ */ package org.keycloak.testsuite.util; +import org.keycloak.dom.saml.v2.SAML2Object; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; import org.keycloak.testsuite.page.AbstractPage; import org.keycloak.testsuite.util.SamlClient.Binding; @@ -35,13 +36,14 @@ import org.keycloak.testsuite.util.saml.LoginBuilder; import org.keycloak.testsuite.util.saml.UpdateProfileBuilder; import org.keycloak.testsuite.util.saml.ModifySamlResponseStepBuilder; import org.keycloak.testsuite.util.saml.RequiredConsentBuilder; +import java.util.function.Function; import javax.ws.rs.core.Response.Status; -import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.hamcrest.Matcher; import org.junit.Assert; import org.w3c.dom.Document; import static org.hamcrest.Matchers.notNullValue; +import static org.keycloak.testsuite.util.saml.SamlDocumentStepBuilder.saml2Object2String; /** * @@ -114,6 +116,10 @@ public class SamlClientBuilder { return this; } + public T andThen(Function next) { + return next.apply(this); + } + public SamlClientBuilder assertResponse(Matcher matcher) { steps.add((client, currentURI, currentResponse, context) -> { Assert.assertThat(currentResponse, matcher); @@ -164,6 +170,22 @@ public class SamlClientBuilder { return addStepBuilder(new CreateLogoutRequestStepBuilder(authServerSamlUrl, issuer, requestBinding, this)); } + /** Issues the given SAML document to the SAML endpoint */ + public ModifySamlResponseStepBuilder submitSamlDocument(URI authServerSamlUrl, String samlDocument, Binding binding) { + return addStepBuilder(new ModifySamlResponseStepBuilder(binding, this) + .targetUri(authServerSamlUrl) + .documentSupplier(() -> samlDocument) + ); + } + + /** Issues the given SAML document to the SAML endpoint */ + public ModifySamlResponseStepBuilder submitSamlDocument(URI authServerSamlUrl, SAML2Object samlObject, Binding binding) { + return addStepBuilder(new ModifySamlResponseStepBuilder(binding, this) + .targetUri(authServerSamlUrl) + .documentSupplier(() -> saml2Object2String(samlObject)) + ); + } + /** Handles login page */ public LoginBuilder login() { return addStepBuilder(new LoginBuilder(this)); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java index 9a11484435..c5b10081b8 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/ModifySamlResponseStepBuilder.java @@ -29,6 +29,8 @@ import java.net.URISyntaxException; import java.util.Iterator; import java.util.LinkedList; import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; import javax.ws.rs.core.Response.Status; import org.apache.commons.io.IOUtils; import org.apache.http.NameValuePair; @@ -62,6 +64,7 @@ public class ModifySamlResponseStepBuilder extends SamlDocumentStepBuilder documentSupplier; public ModifySamlResponseStepBuilder(Binding binding, SamlClientBuilder clientBuilder) { super(clientBuilder); @@ -83,6 +86,15 @@ public class ModifySamlResponseStepBuilder extends SamlDocumentStepBuilder documentSupplier() { + return documentSupplier; + } + + public ModifySamlResponseStepBuilder documentSupplier(Supplier documentSupplier) { + this.documentSupplier = documentSupplier; + return this; + } + public Binding targetBinding() { return targetBinding; } @@ -119,86 +131,108 @@ public class ModifySamlResponseStepBuilder extends SamlDocumentStepBuilder params; - assertThat(currentResponse, statusCodeIsHC(Status.FOUND)); - String location = currentResponse.getFirstHeader("Location").getValue(); - URI locationUri = URI.create(location); + if (documentSupplier != null) { + Objects.requireNonNull(this.targetUri, "Set targetUri"); + Objects.requireNonNull(this.targetAttribute, "Set targetAttribute"); - List params = URLEncodedUtils.parse(locationUri, "UTF-8"); - for (Iterator it = params.iterator(); it.hasNext();) { - NameValuePair param = it.next(); - if ("SAMLResponse".equals(param.getName()) || "SAMLRequest".equals(param.getName())) { - assertThat("Only one SAMLRequest/SAMLResponse check", samlParam, nullValue()); - samlParam = param; - it.remove(); + samlDoc = documentSupplier.get(); + uri = this.targetUri; + attrName = this.targetAttribute; + params = new LinkedList<>(); + } else { + NameValuePair samlParam = null; + + assertThat(currentResponse, statusCodeIsHC(Status.FOUND)); + String location = currentResponse.getFirstHeader("Location").getValue(); + URI locationUri = URI.create(location); + + params = URLEncodedUtils.parse(locationUri, "UTF-8"); + for (Iterator it = params.iterator(); it.hasNext();) { + NameValuePair param = it.next(); + if ("SAMLResponse".equals(param.getName()) || "SAMLRequest".equals(param.getName())) { + assertThat("Only one SAMLRequest/SAMLResponse check", samlParam, nullValue()); + samlParam = param; + it.remove(); + } } + + assertThat(samlParam, notNullValue()); + + String base64EncodedSamlDoc = samlParam.getValue(); + InputStream decoded = RedirectBindingUtil.base64DeflateDecode(base64EncodedSamlDoc); + samlDoc = IOUtils.toString(decoded, GeneralConstants.SAML_CHARSET); + IOUtils.closeQuietly(decoded); + + uri = this.targetUri != null + ? this.targetUri + : locationUri; + attrName = this.targetAttribute != null ? this.targetAttribute : samlParam.getName(); } - assertThat(samlParam, notNullValue()); - - String base64EncodedSamlDoc = samlParam.getValue(); - InputStream decoded = RedirectBindingUtil.base64DeflateDecode(base64EncodedSamlDoc); - String samlDoc = IOUtils.toString(decoded, GeneralConstants.SAML_CHARSET); - IOUtils.closeQuietly(decoded); - - String transformed = getTransformer().transform(samlDoc); - if (transformed == null) { - return null; - } - - final String attrName = this.targetAttribute != null ? this.targetAttribute : samlParam.getName(); - - final URI uri = this.targetUri != null - ? this.targetUri - : locationUri; - - return createRequest(uri, attrName, transformed, params); + return createRequest(uri, attrName, samlDoc, params); } private HttpUriRequest handlePostBinding(CloseableHttpResponse currentResponse) throws Exception { - assertThat(currentResponse, statusCodeIsHC(Status.OK)); + String samlDoc; + final String attrName; + final URI uri; + final List params = new LinkedList<>(); - final String htmlBody = EntityUtils.toString(currentResponse.getEntity()); - assertThat(htmlBody, Matchers.containsString("SAML")); - org.jsoup.nodes.Document theResponsePage = Jsoup.parse(htmlBody); - Elements samlResponses = theResponsePage.select("input[name=SAMLResponse]"); - Elements samlRequests = theResponsePage.select("input[name=SAMLRequest]"); - Elements forms = theResponsePage.select("form"); - Elements relayStates = theResponsePage.select("input[name=RelayState]"); - int size = samlResponses.size() + samlRequests.size(); - assertThat("Checking uniqueness of SAMLResponse/SAMLRequest input field in the page", size, is(1)); - assertThat("Checking uniqueness of forms in the page", forms, hasSize(1)); + if (documentSupplier != null) { + Objects.requireNonNull(this.targetUri, "Set targetUri"); + Objects.requireNonNull(this.targetAttribute, "Set targetAttribute"); - Element respElement = samlResponses.isEmpty() ? samlRequests.first() : samlResponses.first(); - Element form = forms.first(); + samlDoc = documentSupplier.get(); + uri = this.targetUri; + attrName = this.targetAttribute; + } else { + assertThat(currentResponse, statusCodeIsHC(Status.OK)); - String base64EncodedSamlDoc = respElement.val(); - InputStream decoded = PostBindingUtil.base64DecodeAsStream(base64EncodedSamlDoc); - String samlDoc = IOUtils.toString(decoded, GeneralConstants.SAML_CHARSET); - IOUtils.closeQuietly(decoded); + final String htmlBody = EntityUtils.toString(currentResponse.getEntity()); + assertThat(htmlBody, Matchers.containsString("SAML")); + org.jsoup.nodes.Document theResponsePage = Jsoup.parse(htmlBody); + Elements samlResponses = theResponsePage.select("input[name=SAMLResponse]"); + Elements samlRequests = theResponsePage.select("input[name=SAMLRequest]"); + Elements forms = theResponsePage.select("form"); + Elements relayStates = theResponsePage.select("input[name=RelayState]"); + int size = samlResponses.size() + samlRequests.size(); + assertThat("Checking uniqueness of SAMLResponse/SAMLRequest input field in the page", size, is(1)); + assertThat("Checking uniqueness of forms in the page", forms, hasSize(1)); + Element respElement = samlResponses.isEmpty() ? samlRequests.first() : samlResponses.first(); + Element form = forms.first(); + + String base64EncodedSamlDoc = respElement.val(); + InputStream decoded = PostBindingUtil.base64DecodeAsStream(base64EncodedSamlDoc); + samlDoc = IOUtils.toString(decoded, GeneralConstants.SAML_CHARSET); + IOUtils.closeQuietly(decoded); + + attrName = this.targetAttribute != null + ? this.targetAttribute + : respElement.attr("name"); + + if (! relayStates.isEmpty()) { + params.add(new BasicNameValuePair(GeneralConstants.RELAY_STATE, relayStates.first().val())); + } + uri = this.targetUri != null + ? this.targetUri + : URI.create(form.attr("action")); + } + + return createRequest(uri, attrName, samlDoc, params); + } + + protected HttpUriRequest createRequest(URI locationUri, String attributeName, String samlDoc, List parameters) throws Exception { String transformed = getTransformer().transform(samlDoc); if (transformed == null) { return null; } - final String attributeName = this.targetAttribute != null - ? this.targetAttribute - : respElement.attr("name"); - List parameters = new LinkedList<>(); - - if (! relayStates.isEmpty()) { - parameters.add(new BasicNameValuePair(GeneralConstants.RELAY_STATE, relayStates.first().val())); - } - URI locationUri = this.targetUri != null - ? this.targetUri - : URI.create(form.attr("action")); - - return createRequest(locationUri, attributeName, transformed, parameters); - } - - protected HttpUriRequest createRequest(URI locationUri, String attributeName, String transformed, List parameters) throws IOException, URISyntaxException { switch (this.targetBinding) { case POST: return createPostRequest(locationUri, attributeName, transformed, parameters); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlDocumentStepBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlDocumentStepBuilder.java index c712eff792..31310c8961 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlDocumentStepBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/saml/SamlDocumentStepBuilder.java @@ -26,6 +26,7 @@ import org.keycloak.dom.saml.v2.protocol.LogoutRequestType; import org.keycloak.dom.saml.v2.protocol.ResponseType; import org.keycloak.dom.saml.v2.protocol.StatusResponseType; import org.keycloak.saml.common.constants.GeneralConstants; +import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.util.DocumentUtil; import org.keycloak.saml.common.util.StaxUtil; import org.keycloak.saml.processing.api.saml.v2.response.SAML2Response; @@ -36,6 +37,7 @@ import org.keycloak.testsuite.util.SamlClient.Step; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.util.function.Consumer; +import java.util.logging.Level; import javax.xml.stream.XMLStreamWriter; import org.jboss.logging.Logger; import org.junit.Assert; @@ -97,9 +99,18 @@ public abstract class SamlDocumentStepBuilder %s", res); + return res; + }; + return (This) this; + } + + public static String saml2Object2String(final SAML2Object transformed) { + try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); XMLStreamWriter xmlStreamWriter = StaxUtil.getXMLStreamWriter(bos); - + if (transformed instanceof AuthnRequestType) { new SAMLRequestWriter(xmlStreamWriter).write((AuthnRequestType) transformed); } else if (transformed instanceof LogoutRequestType) { @@ -118,11 +129,10 @@ public abstract class SamlDocumentStepBuilder", transformed); Assert.fail("Unknown type: " + transformed.getClass().getName()); } - String res = new String(bos.toByteArray(), GeneralConstants.SAML_CHARSET); - LOG.debugf(" ---> %s", res); - return res; - }; - return (This) this; + return new String(bos.toByteArray(), GeneralConstants.SAML_CHARSET); + } catch (ProcessingException ex) { + throw new RuntimeException(ex); + } } public This transformDocument(Consumer tr) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/AbstractLDAPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/AbstractLDAPTest.java index 441a83b7dd..6ab2954726 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/AbstractLDAPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/AbstractLDAPTest.java @@ -106,12 +106,8 @@ public abstract class AbstractLDAPTest extends AbstractTestRealmKeycloakTest { protected ComponentRepresentation findMapperRepByName(String name) { - List mappers = testRealm().components().query(ldapModelId, LDAPStorageMapper.class.getName()); - for (ComponentRepresentation mapper : mappers) { - if (mapper.getName().equals(name)) { - return mapper; - } - } - return null; + return testRealm().components().query(ldapModelId, LDAPStorageMapper.class.getName()).stream() + .filter(mapper -> mapper.getName().equals(name)) + .findAny().orElse(null); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPSamlIdPInitiatedVaryingLetterCaseTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPSamlIdPInitiatedVaryingLetterCaseTest.java new file mode 100644 index 0000000000..6f869a623b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPSamlIdPInitiatedVaryingLetterCaseTest.java @@ -0,0 +1,299 @@ +/* + * Copyright 2020 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.federation.ldap; + +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.IdentityProviderResource; +import org.keycloak.authentication.authenticators.broker.IdpAutoLinkAuthenticatorFactory; +import org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticatorFactory; +import org.keycloak.broker.saml.SAMLIdentityProviderConfig; +import org.keycloak.broker.saml.mappers.UsernameTemplateMapper; +import org.keycloak.broker.saml.mappers.UsernameTemplateMapper.Target; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.saml.SAML2LoginResponseBuilder; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.storage.ldap.idm.model.LDAPObject; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapper; +import org.keycloak.storage.ldap.mappers.UserAttributeLDAPStorageMapperFactory; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.broker.KcSamlBrokerConfiguration; +import org.keycloak.testsuite.pages.AppPage; +import org.keycloak.testsuite.updaters.Creator; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.LDAPRule; +import org.keycloak.testsuite.util.LDAPTestUtils; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.SamlClient.Binding; +import org.keycloak.testsuite.util.SamlClientBuilder; +import com.google.common.collect.ImmutableMap; +import java.net.URI; +import java.util.UUID; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriBuilderException; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertThat; +import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_SAML_ALIAS; +import static org.keycloak.testsuite.federation.ldap.AbstractLDAPTest.TEST_REALM_NAME; +import static org.keycloak.testsuite.federation.ldap.AbstractLDAPTest.ldapModelId; + +/** + * + * @author hmlnarik + */ +public class LDAPSamlIdPInitiatedVaryingLetterCaseTest extends AbstractLDAPTest { + + @ClassRule + public static LDAPRule ldapRule = new LDAPRule(); + + private static final String USER_NAME_LDAP = "JdOe"; + private static final String USER_NAME_LOWERCASE = USER_NAME_LDAP.toLowerCase(); + private static final String USER_NAME_UPPERCASE = USER_NAME_LDAP.toUpperCase(); + private static final String USER_FIRST_NAME = "Joe"; + private static final String USER_LAST_NAME = "Doe"; + private static final String USER_PASSWORD = "P@ssw0rd!"; + private static final String USER_EMAIL = "jdoe@keycloak.org"; + private static final String USER_STREET = "Street"; + private static final String USER_POSTAL_CODE = "Post code"; + + private static final String MY_APP = "myapp"; + private static final String EXT_SSO = "sso"; + private static final String EXT_SSO_URL = "http://localhost-" + EXT_SSO + ".127.0.0.1.nip.io"; + private static final String DUMMY_URL = "http://localhost-" + EXT_SSO + "-dummy.127.0.0.1.nip.io"; + private static final String FLOW_AUTO_LINK = "AutoLink"; + + private String idpAlias; + + @Override + protected LDAPRule getLDAPRule() { + return ldapRule; + } + + @Override + protected void afterImportTestRealm() { + getTestingClient().server().run(session -> { + LDAPTestContext ctx = LDAPTestContext.init(session); + RealmModel appRealm = ctx.getRealm(); + + // Delete all LDAP users + LDAPTestUtils.removeAllLDAPUsers(ctx.getLdapProvider(), appRealm); + // Add some new LDAP users for testing + LDAPObject user = LDAPTestUtils.addLDAPUser + ( + ctx.getLdapProvider(), + appRealm, + USER_NAME_LDAP, + USER_FIRST_NAME, + USER_LAST_NAME, + USER_EMAIL, + USER_STREET, + USER_POSTAL_CODE + ); + LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), user, USER_PASSWORD); + }); + + ComponentRepresentation ldap = testRealm().components().query(null, "org.keycloak.storage.UserStorageProvider").get(0); + ComponentRepresentation ldapMapper = new ComponentRepresentation(); + ldapMapper.setName("uid-to-user-attr-mapper"); + ldapMapper.setProviderId(UserAttributeLDAPStorageMapperFactory.PROVIDER_ID); + ldapMapper.setProviderType("org.keycloak.storage.ldap.mappers.LDAPStorageMapper"); + ldapMapper.setParentId(ldap.getId()); + MultivaluedHashMap config = new MultivaluedHashMap<>(); + config.add(UserAttributeLDAPStorageMapper.USER_MODEL_ATTRIBUTE, "ldapUid"); + config.add(UserAttributeLDAPStorageMapper.LDAP_ATTRIBUTE, "uid"); + config.add(UserAttributeLDAPStorageMapper.READ_ONLY, "true"); + config.add(UserAttributeLDAPStorageMapper.IS_MANDATORY_IN_LDAP, "true"); + ldapMapper.setConfig(config); + testRealm().components().add(ldapMapper); + } + + @Before + public void setupIdentityProvider() { + // Configure autolink flow + AuthenticationFlowRepresentation newFlow = new AuthenticationFlowRepresentation(); + newFlow.setAlias(FLOW_AUTO_LINK); + newFlow.setDescription("Auto-link flow"); + newFlow.setProviderId("basic-flow"); + newFlow.setBuiltIn(false); + newFlow.setTopLevel(true); + + Creator.Flow amr = Creator.create(testRealm(), newFlow); + + AuthenticationExecutionInfoRepresentation exCreateUser = amr.addExecution(IdpCreateUserIfUniqueAuthenticatorFactory.PROVIDER_ID); + exCreateUser.setRequirement(Requirement.ALTERNATIVE.name()); + testRealm().flows().updateExecutions(FLOW_AUTO_LINK, exCreateUser); + + AuthenticationExecutionInfoRepresentation exAutoLink = amr.addExecution(IdpAutoLinkAuthenticatorFactory.PROVIDER_ID); + exAutoLink.setRequirement(Requirement.ALTERNATIVE.name()); + testRealm().flows().updateExecutions(FLOW_AUTO_LINK, exAutoLink); + getCleanup().addCleanup(amr); + + // Configure identity provider + IdentityProviderRepresentation idp = KcSamlBrokerConfiguration.INSTANCE.setUpIdentityProvider(); + idp.getConfig().put(SAMLIdentityProviderConfig.NAME_ID_POLICY_FORMAT, JBossSAMLURIConstants.NAMEID_FORMAT_UNSPECIFIED.get()); + idp.setFirstBrokerLoginFlowAlias(FLOW_AUTO_LINK); + final Creator idpCreator = Creator.create(testRealm(), idp); + + IdentityProviderMapperRepresentation samlNameIdMapper = new IdentityProviderMapperRepresentation(); + samlNameIdMapper.setName("username-nameid-mapper"); + idpAlias = idp.getAlias(); + samlNameIdMapper.setIdentityProviderAlias(idpAlias); + samlNameIdMapper.setIdentityProviderMapper(UsernameTemplateMapper.PROVIDER_ID); + samlNameIdMapper.setConfig(ImmutableMap.builder() + .put(IdentityProviderMapperModel.SYNC_MODE, "IMPORT") + .put(UsernameTemplateMapper.TEMPLATE, "${NAMEID | lowercase}") + .put(UsernameTemplateMapper.TARGET, Target.BROKER_ID.name()) + .build()); + idpCreator.resource().addMapper(samlNameIdMapper); + + getCleanup().addCleanup(idpCreator); + } + + @Before + public void setupClients() { + getCleanup().addCleanup(Creator.create(testRealm(), ClientBuilder.create() + .protocol(SamlProtocol.LOGIN_PROTOCOL) + .clientId(EXT_SSO_URL) + .baseUrl(EXT_SSO_URL) + .attribute(SamlProtocol.SAML_IDP_INITIATED_SSO_URL_NAME, EXT_SSO) + .attribute(SamlProtocol.SAML_NAME_ID_FORMAT, JBossSAMLURIConstants.NAMEID_FORMAT_PERSISTENT.get()) + .attribute(SamlProtocol.SAML_ASSERTION_CONSUMER_URL_POST_ATTRIBUTE, DUMMY_URL) + .build()) + ); + + getCleanup().addCleanup(Creator.create(testRealm(), ClientBuilder.create() + .clientId(MY_APP) + .protocol(OIDCLoginProtocol.LOGIN_PROTOCOL) + .baseUrl(oauth.APP_AUTH_ROOT) + .build()) + ); + } + + @After + public void cleanupUsers() { + testRealm().userStorage().removeImportedUsers(ldapModelId); + } + + @Test + public void loginLDAPTest() { + loginPage.open(); + loginPage.login(USER_NAME_LDAP, USER_PASSWORD); + appPage.assertCurrent(); + Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); + appPage.logout(); + } + + protected URI getAuthServerBrokerSamlEndpoint(String realm, String identityProviderAlias, String samlClientId) throws IllegalArgumentException, UriBuilderException { + return RealmsResource + .realmBaseUrl(UriBuilder.fromUri(getAuthServerRoot())) + .path("broker/{idp-name}/endpoint/clients/{client-id}") + .build(realm, identityProviderAlias, samlClientId); + } + + @Test + public void idpInitiatedMatchCaseLDAPTest() throws Exception { + testIdpInitiated(USER_NAME_LDAP, true); + } + + @Test + public void idpInitiatedUpperCaseLDAPTest() throws Exception { + testIdpInitiated(USER_NAME_UPPERCASE, true); + } + + @Test + public void idpInitiatedLowerCaseLDAPTest() throws Exception { + testIdpInitiated(USER_NAME_LOWERCASE, true); + } + + @Test + public void idpInitiatedVaryingLetterCasesLDAPTest() throws Exception { + testIdpInitiated(USER_NAME_LDAP, true); + testIdpInitiated(USER_NAME_UPPERCASE, false); + testIdpInitiated(USER_NAME_LOWERCASE, false); + } + + private void testIdpInitiated(String userName, boolean isFirstBrokerLogin) throws Exception { + final URI destination = getAuthServerBrokerSamlEndpoint(TEST_REALM_NAME, IDP_SAML_ALIAS, EXT_SSO); + ResponseType response = prepareResponseForIdPInitiatedFlow(destination, userName); + + final SamlClientBuilder builder = new SamlClientBuilder() + // Create user session via IdP-initiated login + .submitSamlDocument(destination, response, Binding.POST) + .targetAttributeSamlResponse() + .build(); + + if (isFirstBrokerLogin) { + builder + // First-broker login + .followOneRedirect() + + // After first-broker login + .followOneRedirect(); + } + + builder + // Do not truly process SAML POST response for a virtual IdP-initiated client, just check that no error was reported + .processSamlResponse(Binding.POST) + .transformObject(so -> { + assertThat(so, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + return null; + }) + .build() + + // Now navigate to the application where the session should already be created + .navigateTo(oauth.getLoginFormUrl()) + + .assertResponse(Matchers.bodyHC(containsString("AUTH_RESPONSE"))) + .execute(); + + assertThat(testRealm().users().search(USER_NAME_LDAP, Boolean.TRUE), hasSize(1)); + } + + private ResponseType prepareResponseForIdPInitiatedFlow(final URI destination, String userName) throws ConfigurationException, ProcessingException { + // Prepare Response for IdP-initiated flow + return new SAML2LoginResponseBuilder() + .requestID(UUID.randomUUID().toString()) + .destination(destination.toString()) + .issuer(EXT_SSO_URL) + .requestIssuer(destination.toString()) + .assertionExpiration(1000000) + .subjectExpiration(1000000) + .sessionIndex("idp:" + UUID.randomUUID()) + .nameIdentifier(JBossSAMLURIConstants.NAMEID_FORMAT_PERSISTENT.get(), userName) + .buildModel(); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java index 9c06c40a27..fecb774a76 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/saml/AbstractSamlTest.java @@ -93,6 +93,13 @@ public abstract class AbstractSamlTest extends AbstractAuthTest { .build(realm, SamlProtocol.LOGIN_PROTOCOL); } + protected URI getAuthServerBrokerSamlEndpoint(String realm, String identityProviderAlias) throws IllegalArgumentException, UriBuilderException { + return RealmsResource + .realmBaseUrl(UriBuilder.fromUri(getAuthServerRoot())) + .path("broker/{idp-name}/endpoint") + .build(realm, identityProviderAlias); + } + protected URI getAuthServerRealmBase(String realm) throws IllegalArgumentException, UriBuilderException { return RealmsResource .realmBaseUrl(UriBuilder.fromUri(getAuthServerRoot())) diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/jboss-deployment-structure.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/jboss-deployment-structure.xml index aa63c28580..8fc021992a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/jboss-deployment-structure.xml +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/jboss-deployment-structure.xml @@ -24,6 +24,8 @@ + +