diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/constants/X500SAMLProfileConstants.java b/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/constants/X500SAMLProfileConstants.java index 0c6dbc61cf..25ffd992cd 100755 --- a/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/constants/X500SAMLProfileConstants.java +++ b/saml-core/src/main/java/org/keycloak/saml/processing/core/saml/v2/constants/X500SAMLProfileConstants.java @@ -16,9 +16,12 @@ */ package org.keycloak.saml.processing.core.saml.v2.constants; +import org.keycloak.dom.saml.v2.assertion.AttributeType; + import java.util.EnumSet; import java.util.HashMap; import java.util.Map; +import java.util.Objects; /** * X500 SAML Profile Constants Adapted from @@ -147,6 +150,12 @@ public enum X500SAMLProfileConstants { return friendlyName; } + public boolean correspondsTo(AttributeType attribute) { + return attribute != null + ? Objects.equals(this.uri, attribute.getName()) || Objects.equals(this.friendlyName, attribute.getFriendlyName()) + : false; + } + public static String getOID(final String key) { return lookup.get(key); } diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java index 971d941311..5f4083af22 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLEndpoint.java @@ -85,7 +85,9 @@ import java.security.Key; import java.security.cert.X509Certificate; import java.util.LinkedList; import java.util.List; +import java.util.function.Predicate; +import org.keycloak.protocol.saml.SamlPrincipalType; import org.keycloak.rotation.HardcodedKeyLocator; import org.keycloak.rotation.KeyLocator; import org.keycloak.saml.processing.core.util.KeycloakKeySamlExtensionGenerator; @@ -414,15 +416,24 @@ public class SAMLEndpoint { SubjectType subject = assertion.getSubject(); SubjectType.STSubType subType = subject.getSubType(); NameIDType subjectNameID = (NameIDType) subType.getBaseID(); + String principal = getPrincipal(assertion); + + if (principal == null) { + logger.errorf("no principal in assertion; expected: %s", expectedPrincipalType()); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.INVALID_SAML_RESPONSE); + return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.INVALID_REQUESTER); + } + //Map notes = new HashMap<>(); - BrokeredIdentityContext identity = new BrokeredIdentityContext(subjectNameID.getValue()); + BrokeredIdentityContext identity = new BrokeredIdentityContext(principal); identity.getContextData().put(SAML_LOGIN_RESPONSE, responseType); identity.getContextData().put(SAML_ASSERTION, assertion); if (clientId != null && ! clientId.trim().isEmpty()) { identity.getContextData().put(SAML_IDP_INITIATED_CLIENT_ID, clientId); } - identity.setUsername(subjectNameID.getValue()); + identity.setUsername(principal); //SAML Spec 2.2.2 Format is optional if (subjectNameID.getFormat() != null && subjectNameID.getFormat().toString().equals(JBossSAMLURIConstants.NAMEID_FORMAT_EMAIL.get())) { @@ -461,19 +472,12 @@ public class SAMLEndpoint { } } if (assertion.getAttributeStatements() != null ) { - for (AttributeStatementType attrStatement : assertion.getAttributeStatements()) { - for (AttributeStatementType.ASTChoiceType choice : attrStatement.getAttributes()) { - AttributeType attribute = choice.getAttribute(); - if (X500SAMLProfileConstants.EMAIL.getFriendlyName().equals(attribute.getFriendlyName()) - || X500SAMLProfileConstants.EMAIL.get().equals(attribute.getName())) { - if (!attribute.getAttributeValue().isEmpty()) identity.setEmail(attribute.getAttributeValue().get(0).toString()); - } - } - - } - + String email = getX500Attribute(assertion, X500SAMLProfileConstants.EMAIL); + if (email != null) + identity.setEmail(email); } - String brokerUserId = config.getAlias() + "." + subjectNameID.getValue(); + + String brokerUserId = config.getAlias() + "." + principal; identity.setBrokerUserId(brokerUserId); identity.setIdpConfig(config); identity.setIdp(provider); @@ -632,4 +636,59 @@ public class SAMLEndpoint { } + private String getX500Attribute(AssertionType assertion, X500SAMLProfileConstants attribute) { + return getFirstMatchingAttribute(assertion, attribute::correspondsTo); + } + + private String getAttributeByName(AssertionType assertion, String name) { + return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getName(), name)); + } + + private String getAttributeByFriendlyName(AssertionType assertion, String friendlyName) { + return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getFriendlyName(), friendlyName)); + } + + private String getPrincipal(AssertionType assertion) { + + SamlPrincipalType principalType = config.getPrincipalType(); + + if (principalType == null || principalType.equals(SamlPrincipalType.SUBJECT)) { + SubjectType subject = assertion.getSubject(); + SubjectType.STSubType subType = subject.getSubType(); + NameIDType subjectNameID = (NameIDType) subType.getBaseID(); + return subjectNameID.getValue(); + } else if (principalType.equals(SamlPrincipalType.ATTRIBUTE)) { + return getAttributeByName(assertion, config.getPrincipalAttribute()); + } else { + return getAttributeByFriendlyName(assertion, config.getPrincipalAttribute()); + } + + } + + private String getFirstMatchingAttribute(AssertionType assertion, Predicate predicate) { + return assertion.getAttributeStatements().stream() + .map(AttributeStatementType::getAttributes) + .flatMap(Collection::stream) + .map(AttributeStatementType.ASTChoiceType::getAttribute) + .filter(predicate) + .map(AttributeType::getAttributeValue) + .flatMap(Collection::stream) + .findFirst() + .map(Object::toString) + .orElse(null); + } + + private String expectedPrincipalType() { + SamlPrincipalType principalType = config.getPrincipalType(); + switch (principalType) { + case SUBJECT: + return principalType.name(); + case ATTRIBUTE: + case FRIENDLY_ATTRIBUTE: + return String.format("%s(%s)", principalType.name(), config.getPrincipalAttribute()); + default: + return null; + } + } + } diff --git a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java index 173c4814ac..37fa434c58 100755 --- a/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java +++ b/services/src/main/java/org/keycloak/broker/saml/SAMLIdentityProviderConfig.java @@ -18,6 +18,7 @@ package org.keycloak.broker.saml; import org.keycloak.models.IdentityProviderModel; +import org.keycloak.protocol.saml.SamlPrincipalType; import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer; /** @@ -40,6 +41,8 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel { public static final String SINGLE_LOGOUT_SERVICE_URL = "singleLogoutServiceUrl"; public static final String SINGLE_SIGN_ON_SERVICE_URL = "singleSignOnServiceUrl"; public static final String VALIDATE_SIGNATURE = "validateSignature"; + public static final String PRINCIPAL_TYPE = "principalType"; + public static final String PRINCIPAL_ATTRIBUTE = "principalAttribute"; public static final String WANT_ASSERTIONS_ENCRYPTED = "wantAssertionsEncrypted"; public static final String WANT_ASSERTIONS_SIGNED = "wantAssertionsSigned"; public static final String WANT_AUTHN_REQUESTS_SIGNED = "wantAuthnRequestsSigned"; @@ -253,4 +256,24 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel { getConfig().put(ALLOWED_CLOCK_SKEW, String.valueOf(allowedClockSkew)); } } + + public SamlPrincipalType getPrincipalType() { + return SamlPrincipalType.from(getConfig().get(PRINCIPAL_TYPE), SamlPrincipalType.SUBJECT); + } + + public void setPrincipalType(SamlPrincipalType principalType) { + getConfig().put(PRINCIPAL_TYPE, + principalType == null + ? null + : principalType.name()); + } + + public String getPrincipalAttribute() { + return getConfig().get(PRINCIPAL_ATTRIBUTE); + } + + public void setPrincipalAttribute(String principalAttribute) { + getConfig().put(PRINCIPAL_ATTRIBUTE, principalAttribute); + } + } diff --git a/services/src/main/java/org/keycloak/protocol/saml/SamlPrincipalType.java b/services/src/main/java/org/keycloak/protocol/saml/SamlPrincipalType.java new file mode 100644 index 0000000000..9c76f3a55e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/saml/SamlPrincipalType.java @@ -0,0 +1,36 @@ +/* + * 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.protocol.saml; + +public enum SamlPrincipalType { + + SUBJECT, + ATTRIBUTE, + FRIENDLY_ATTRIBUTE; + + public static SamlPrincipalType from(String name, SamlPrincipalType defaultValue) { + if (name == null) { + return defaultValue; + } + try { + return valueOf(name); + } catch (IllegalArgumentException ex) { + return defaultValue; + } + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlIdPInitiatedSsoTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlIdPInitiatedSsoTest.java index 03f6f6d0b3..1910203c3e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlIdPInitiatedSsoTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcSamlIdPInitiatedSsoTest.java @@ -1,18 +1,27 @@ package org.keycloak.testsuite.broker; import org.keycloak.admin.client.resource.ClientsResource; +import org.keycloak.admin.client.resource.IdentityProviderResource; import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.broker.saml.SAMLIdentityProviderConfig; import org.keycloak.common.util.StreamUtil; import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType; +import org.keycloak.dom.saml.v2.assertion.StatementAbstractType; import org.keycloak.dom.saml.v2.protocol.ResponseType; +import org.keycloak.protocol.saml.SamlPrincipalType; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.FederatedIdentityRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserSessionRepresentation; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.saml.processing.core.saml.v2.constants.X500SAMLProfileConstants; import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.Assert; @@ -109,6 +118,14 @@ public class KcSamlIdPInitiatedSsoTest extends AbstractKeycloakTest { urlRealmConsumer2 = getAuthRoot() + "/auth/realms/" + REALM_CONS_NAME + "-2"; } + @Before + public void resetPrincipalType() { + IdentityProviderResource idp = adminClient.realm(REALM_CONS_NAME).identityProviders().get("saml-leaf"); + IdentityProviderRepresentation rep = idp.toRepresentation(); + rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_TYPE, SamlPrincipalType.SUBJECT.name()); + idp.update(rep); + } + @Override public void addTestRealms(List testRealms) { initRealmUrls(); @@ -324,6 +341,62 @@ public class KcSamlIdPInitiatedSsoTest extends AbstractKeycloakTest { ); } + // KEYCLOAK-7969 + @Test + public void testProviderIdpInitiatedLoginWithPrincipalAttribute() throws Exception { + IdentityProviderResource idp = adminClient.realm(REALM_CONS_NAME).identityProviders().get("saml-leaf"); + IdentityProviderRepresentation rep = idp.toRepresentation(); + rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_TYPE, SamlPrincipalType.ATTRIBUTE.name()); + rep.getConfig().put(SAMLIdentityProviderConfig.PRINCIPAL_ATTRIBUTE, X500SAMLProfileConstants.UID.get()); + idp.update(rep); + + SAMLDocumentHolder samlResponse = new SamlClientBuilder() + .navigateTo(getSamlIdpInitiatedUrl(REALM_PROV_NAME, "samlbroker")) + // Login in provider realm + .login().user(PROVIDER_REALM_USER_NAME, PROVIDER_REALM_USER_PASSWORD).build() + + // Send the response to the consumer realm + .processSamlResponse(Binding.POST) + .transformObject(ob -> { + assertThat(ob, Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + ResponseType resp = (ResponseType) ob; + assertThat(resp.getDestination(), is(getSamlBrokerIdpInitiatedUrl(REALM_CONS_NAME, "sales"))); + assertAudience(resp, getSamlBrokerIdpInitiatedUrl(REALM_CONS_NAME, "sales")); + + Set statements = resp.getAssertions().get(0).getAssertion().getStatements(); + + AttributeStatementType attributeType = (AttributeStatementType) statements.stream() + .filter(statement -> statement instanceof AttributeStatementType) + .findFirst().orElse(new AttributeStatementType()); + + AttributeType attr = new AttributeType(X500SAMLProfileConstants.UID.get()); + attr.addAttributeValue(PROVIDER_REALM_USER_NAME); + + attributeType.addAttribute(new AttributeStatementType.ASTChoiceType(attr)); + resp.getAssertions().get(0).getAssertion().addStatement(attributeType); + + return ob; + }) + .build() + + .updateProfile().username(CONSUMER_CHOSEN_USERNAME).email("test@localhost").firstName("Firstname").lastName("Lastname").build() + .followOneRedirect() + + // Obtain the response sent to the app + .getSamlResponse(Binding.POST); + + assertThat(samlResponse.getSamlObject(), Matchers.isSamlResponse(JBossSAMLURIConstants.STATUS_SUCCESS)); + ResponseType resp = (ResponseType) samlResponse.getSamlObject(); + assertThat(resp.getDestination(), is(urlRealmConsumer + "/app/auth")); + assertAudience(resp, urlRealmConsumer + "/app/auth"); + + UsersResource users = adminClient.realm(REALM_CONS_NAME).users(); + String id = users.search(CONSUMER_CHOSEN_USERNAME).get(0).getId(); + FederatedIdentityRepresentation fed = users.get(id).getFederatedIdentity().get(0); + assertThat(fed.getUserId(), is(PROVIDER_REALM_USER_NAME)); + assertThat(fed.getUserName(), is(PROVIDER_REALM_USER_NAME)); + } + private void assertSingleUserSession(String realmName, String userName, String... expectedClientIds) { final UsersResource users = adminClient.realm(realmName).users(); final ClientsResource clients = adminClient.realm(realmName).clients(); diff --git a/themes/src/main/resources-community/theme/base/admin/messages/admin-messages_ru.properties b/themes/src/main/resources-community/theme/base/admin/messages/admin-messages_ru.properties index 2084e7fbbd..0d036defc4 100644 --- a/themes/src/main/resources-community/theme/base/admin/messages/admin-messages_ru.properties +++ b/themes/src/main/resources-community/theme/base/admin/messages/admin-messages_ru.properties @@ -527,6 +527,10 @@ single-logout-service-url=Адреса сервиса единого выход saml.single-logout-service-url.tooltip=Url, который должен быть использован для отправленных запросов на выход. nameid-policy-format=Формат политики NameID nameid-policy-format.tooltip=Определяет ссылку URI, соответствующую формату идентификатора имени. По умолчанию urn:oasis:names:tc:SAML:2.0:nameid-format:persistent. +saml.principal-type=Тип идентификации +saml.principal-type.tooltip=Определяет, каким образом Keycloak идентифицирует внешних пользователей по SAML-сообщению. По умолчанию идентификация происходит по Subject NameID, в качестве альтернативы можно использовать атрибут-идентификатор. +saml.principal-attribute=Атрибут-идентификатор +saml.principal-attribute.tooltip=Имя (Name) или "дружественное имя" (Friendly Name) атрибута, идентифицирующего внешних пользователей. http-post-binding-response=Привязанный ответ HTTP-POST http-post-binding-response.tooltip=Указывает, следует ли отвечать на запросы, используя привязку HTTP-POST. Если нет, то будет использован HTTP-REDIRECT. http-post-binding-for-authn-request=Привязывание HTTP-POST для AuthnRequest diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index b5bc230215..909d4e118b 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -643,6 +643,10 @@ single-logout-service-url=Single Logout Service URL saml.single-logout-service-url.tooltip=The Url that must be used to send logout requests. nameid-policy-format=NameID Policy Format nameid-policy-format.tooltip=Specifies the URI reference corresponding to a name identifier format. Defaults to urn:oasis:names:tc:SAML:2.0:nameid-format:persistent. +saml.principal-type=Principal Type +saml.principal-type.tooltip=Way to identify and track external users from the assertion. Default is using Subject NameID, alternatively you can set up identifying attribute. +saml.principal-attribute=Principal Attribute +saml.principal-attribute.tooltip=Name or Friendly Name of the attribute used to identify external users. http-post-binding-response=HTTP-POST Binding Response http-post-binding-response.tooltip=Indicates whether to respond to requests using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used. http-post-binding-for-authn-request=HTTP-POST Binding for AuthnRequest diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 6b7a5f46e6..7ef5d4cae9 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -834,10 +834,28 @@ module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload "KEY_ID", "CERT_SUBJECT" ]; + $scope.principalTypes = [ + { + type: "SUBJECT", + name: "Subject NameID" + + }, + { + type: "ATTRIBUTE", + name: "Attribute [Name]" + + }, + { + type: "FRIENDLY_ATTRIBUTE", + name: "Attribute [Friendly Name]" + + } + ]; if (instance && instance.alias) { } else { $scope.identityProvider.config.nameIDPolicyFormat = $scope.nameIdFormats[0].format; + $scope.identityProvider.config.principalType = $scope.principalTypes[0].type; $scope.identityProvider.config.signatureAlgorithm = $scope.signatureAlgorithms[1]; $scope.identityProvider.config.samlXmlKeyNameTranformer = $scope.xmlKeyNameTranformers[1]; } diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html index 1c74371d1d..4b5b4ac929 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html @@ -142,6 +142,22 @@ {{:: 'nameid-policy-format.tooltip' | translate}} +
+ +
+ +
+ {{:: 'saml.principal-type.tooltip' | translate}} +
+
+ +
+ +
+ {{:: 'saml.principal-attribute.tooltip' | translate}} +