KEYCLOAK-19866 Fix user-defined- and xml-fragment-parsing/Add XPathAttributeMapper
This commit is contained in:
parent
0d3ca438ed
commit
21f700679f
9 changed files with 731 additions and 57 deletions
|
@ -16,6 +16,14 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.saml.processing.core.parsers.saml.assertion;
|
package org.keycloak.saml.processing.core.parsers.saml.assertion;
|
||||||
|
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.xml.stream.XMLEventFactory;
|
||||||
|
import javax.xml.stream.XMLStreamException;
|
||||||
|
import javax.xml.stream.events.Namespace;
|
||||||
import org.keycloak.saml.common.PicketLinkLogger;
|
import org.keycloak.saml.common.PicketLinkLogger;
|
||||||
import org.keycloak.saml.common.PicketLinkLoggerFactory;
|
import org.keycloak.saml.common.PicketLinkLoggerFactory;
|
||||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||||
|
@ -46,6 +54,8 @@ public class SAMLAttributeValueParser implements StaxParser {
|
||||||
private static final QName NIL = new QName(JBossSAMLURIConstants.XSI_NSURI.get(), "nil", JBossSAMLURIConstants.XSI_PREFIX.get());
|
private static final QName NIL = new QName(JBossSAMLURIConstants.XSI_NSURI.get(), "nil", JBossSAMLURIConstants.XSI_PREFIX.get());
|
||||||
private static final QName XSI_TYPE = new QName(JBossSAMLURIConstants.XSI_NSURI.get(), "type", JBossSAMLURIConstants.XSI_PREFIX.get());
|
private static final QName XSI_TYPE = new QName(JBossSAMLURIConstants.XSI_NSURI.get(), "type", JBossSAMLURIConstants.XSI_PREFIX.get());
|
||||||
|
|
||||||
|
private static final ThreadLocal<XMLEventFactory> XML_EVENT_FACTORY = ThreadLocal.withInitial(XMLEventFactory::newInstance);
|
||||||
|
|
||||||
public static SAMLAttributeValueParser getInstance() {
|
public static SAMLAttributeValueParser getInstance() {
|
||||||
return INSTANCE;
|
return INSTANCE;
|
||||||
}
|
}
|
||||||
|
@ -88,7 +98,7 @@ public class SAMLAttributeValueParser implements StaxParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
// when no type attribute assigned -> assume anyType
|
// when no type attribute assigned -> assume anyType
|
||||||
return parseAnyTypeAsString(xmlEventReader);
|
return parseAsString(xmlEventReader);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RK Added an additional type check for base64Binary type as calheers is passing this type
|
// RK Added an additional type check for base64Binary type as calheers is passing this type
|
||||||
|
@ -96,7 +106,7 @@ public class SAMLAttributeValueParser implements StaxParser {
|
||||||
if (typeValue.contains(":string")) {
|
if (typeValue.contains(":string")) {
|
||||||
return StaxParserUtil.getElementText(xmlEventReader);
|
return StaxParserUtil.getElementText(xmlEventReader);
|
||||||
} else if (typeValue.contains(":anyType")) {
|
} else if (typeValue.contains(":anyType")) {
|
||||||
return parseAnyTypeAsString(xmlEventReader);
|
return parseAsString(xmlEventReader);
|
||||||
} else if(typeValue.contains(":base64Binary")){
|
} else if(typeValue.contains(":base64Binary")){
|
||||||
return StaxParserUtil.getElementText(xmlEventReader);
|
return StaxParserUtil.getElementText(xmlEventReader);
|
||||||
} else if(typeValue.contains(":date")){
|
} else if(typeValue.contains(":date")){
|
||||||
|
@ -105,33 +115,29 @@ public class SAMLAttributeValueParser implements StaxParser {
|
||||||
return StaxParserUtil.getElementText(xmlEventReader);
|
return StaxParserUtil.getElementText(xmlEventReader);
|
||||||
}
|
}
|
||||||
|
|
||||||
// KEYCLOAK-18417: Simply ignore unknown types
|
return parseAsString(xmlEventReader);
|
||||||
logger.debug("Skipping attribute value of unsupported type " + typeValue);
|
|
||||||
StaxParserUtil.bypassElementBlock(xmlEventReader);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String parseAnyTypeAsString(XMLEventReader xmlEventReader) throws ParsingException {
|
private static String parseAsString(XMLEventReader xmlEventReader) throws ParsingException {
|
||||||
try {
|
try {
|
||||||
XMLEvent event = xmlEventReader.peek();
|
if (xmlEventReader.peek().isStartElement()) {
|
||||||
if (event.isStartElement()) {
|
|
||||||
event = xmlEventReader.nextTag();
|
|
||||||
StringWriter sw = new StringWriter();
|
StringWriter sw = new StringWriter();
|
||||||
XMLEventWriter writer = XMLOutputFactory.newInstance().createXMLEventWriter(sw);
|
XMLEventWriter writer = XMLOutputFactory.newInstance().createXMLEventWriter(sw);
|
||||||
//QName tagName = event.asStartElement().getName();
|
Deque<Map<String, String>> definedNamespaces = new LinkedList<>();
|
||||||
int tagLevel = 1;
|
int tagLevel = 0;
|
||||||
do {
|
while (xmlEventReader.hasNext() && (tagLevel > 0 || !xmlEventReader.peek().isEndElement())) {
|
||||||
|
XMLEvent event = (XMLEvent) xmlEventReader.next();
|
||||||
writer.add(event);
|
writer.add(event);
|
||||||
event = (XMLEvent) xmlEventReader.next();
|
|
||||||
if (event.isStartElement()) {
|
if (event.isStartElement()) {
|
||||||
|
definedNamespaces.push(addNamespaceWhenMissing(definedNamespaces, writer, event.asStartElement()));
|
||||||
tagLevel++;
|
tagLevel++;
|
||||||
}
|
}
|
||||||
if (event.isEndElement()) {
|
if (event.isEndElement()) {
|
||||||
|
definedNamespaces.pop();
|
||||||
tagLevel--;
|
tagLevel--;
|
||||||
}
|
}
|
||||||
} while (xmlEventReader.hasNext() && tagLevel > 0);
|
}
|
||||||
writer.add(event);
|
writer.close();
|
||||||
writer.flush();
|
|
||||||
return sw.toString();
|
return sw.toString();
|
||||||
} else {
|
} else {
|
||||||
return StaxParserUtil.getElementText(xmlEventReader);
|
return StaxParserUtil.getElementText(xmlEventReader);
|
||||||
|
@ -141,4 +147,38 @@ public class SAMLAttributeValueParser implements StaxParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> addNamespaceWhenMissing(Deque<Map<String, String>> definedNamespaces, XMLEventWriter writer,
|
||||||
|
StartElement startElement) throws XMLStreamException {
|
||||||
|
|
||||||
|
final Map<String, String> necessaryNamespaces = new HashMap<>();
|
||||||
|
// Namespace in tag
|
||||||
|
if (startElement.getName().getPrefix() != null && !startElement.getName().getPrefix().isEmpty()) {
|
||||||
|
necessaryNamespaces.put(startElement.getName().getPrefix(), startElement.getName().getNamespaceURI());
|
||||||
|
}
|
||||||
|
// Namespaces in attributes
|
||||||
|
final Iterator<Attribute> attributes = startElement.getAttributes();
|
||||||
|
while (attributes.hasNext()) {
|
||||||
|
final Attribute attribute = attributes.next();
|
||||||
|
if (attribute.getName().getPrefix() != null && !attribute.getName().getPrefix().isEmpty()) {
|
||||||
|
necessaryNamespaces.put(attribute.getName().getPrefix(), attribute.getName().getNamespaceURI());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already contained in stack
|
||||||
|
necessaryNamespaces.entrySet().removeIf(nn -> definedNamespaces.stream().anyMatch(dn -> dn.containsKey(nn.getKey())));
|
||||||
|
// Contained in current element
|
||||||
|
Iterator<Namespace> namespaces = startElement.getNamespaces();
|
||||||
|
while (namespaces.hasNext() && !necessaryNamespaces.isEmpty()) {
|
||||||
|
necessaryNamespaces.remove(namespaces.next().getPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all remaining necessaryNamespaces
|
||||||
|
if (!necessaryNamespaces.isEmpty()) {
|
||||||
|
XMLEventFactory xmlEventFactory = XML_EVENT_FACTORY.get();
|
||||||
|
for (Map.Entry<String, String> entry : necessaryNamespaces.entrySet()) {
|
||||||
|
writer.add(xmlEventFactory.createNamespace(entry.getKey(), entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return necessaryNamespaces;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,55 +1,113 @@
|
||||||
package org.keycloak.saml.processing.core.parsers.saml;
|
package org.keycloak.saml.processing.core.parsers.saml;
|
||||||
|
|
||||||
|
import javax.xml.stream.events.XMLEvent;
|
||||||
|
import javax.xml.stream.XMLEventReader;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.keycloak.saml.common.parsers.AbstractParser;
|
import org.keycloak.saml.common.parsers.AbstractParser;
|
||||||
import org.keycloak.saml.processing.core.parsers.saml.assertion.SAMLAttributeValueParser;
|
import org.keycloak.saml.processing.core.parsers.saml.assertion.SAMLAttributeValueParser;
|
||||||
|
|
||||||
import javax.xml.stream.XMLEventReader;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
public class SAMLAttributeValueParserTest {
|
public class SAMLAttributeValueParserTest {
|
||||||
|
|
||||||
private static final String XML_DOC =
|
private static final String XML_DOC = "Some Text";
|
||||||
"<saml2:Attribute xmlns:saml2=\"urn:oasis:names:tc:SAML:2.0:assertion\"\n>"
|
|
||||||
+ " <saml2:AttributeValue xmlns:myCustomType=\"http://www.whatever.de/schema/myCustomType/saml/extensions\"\n"
|
|
||||||
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"myCustomType:Something\">\n"
|
|
||||||
+ " Some Text\n"
|
|
||||||
+ " </saml2:AttributeValue>\n"
|
|
||||||
+ "</saml2:Attribute>";
|
|
||||||
|
|
||||||
private static final String XML_DOC_WITH_NESTED_ELEMENTS =
|
private static final String XML_DOC_NESTED_ELEMENTS =
|
||||||
"<saml2:Attribute xmlns:saml2=\"urn:oasis:names:tc:SAML:2.0:assertion\"\n>"
|
"<%1$sStreet%2$s>Zillestraße</%1$sStreet><%1$sHouseNumber%2$s>17</%1$sHouseNumber>"
|
||||||
+ " <saml2:AttributeValue xmlns:myCustomType=\"http://www.whatever.de/schema/myCustomType/saml/extensions\"\n"
|
+ "<%1$sZipCode%2$s>10585</%1$sZipCode><%1$sCity%2$s>Berlin</%1$sCity><%1$sCountry%2$s>DE</%1$sCountry>";
|
||||||
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"myCustomType:AddressType\">\n"
|
|
||||||
+ " <myCustomType:Street>Zillestraße</myCustomType:Street>\n"
|
private static final String XML_DOC_SINGLE_ELEMENT =
|
||||||
+ " <myCustomType:HouseNumber>17</myCustomType:HouseNumber>\n"
|
"<%1$sAddressType%2$s>" + String.format(XML_DOC_NESTED_ELEMENTS, "%1$s", "") + "</%1$sAddressType>";
|
||||||
+ " <myCustomType:ZipCode>10585</myCustomType:ZipCode>\n"
|
|
||||||
+ " <myCustomType:City>Berlin</myCustomType:City>\n"
|
private static final String XML_DOC_NESTED_WITHOUT_PREFIX_AND_NAMESPACE = String.format(XML_DOC_NESTED_ELEMENTS, "", "");
|
||||||
+ " <myCustomType:Country>DE</myCustomType:Country>\n"
|
|
||||||
+ " </saml2:AttributeValue>\n"
|
|
||||||
+ "</saml2:Attribute>";
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void parsesAttributeValueElementWithCustomTypes_ReturnsNull() throws Exception {
|
public void parsesAttributeValueUserType() throws Exception {
|
||||||
InputStream input = new ByteArrayInputStream(XML_DOC.getBytes(StandardCharsets.UTF_8));
|
Object actualAttributeValue = parseAttributeValue("xsi:type=\"myCustomType:Something\"", "\n " + XML_DOC + "\n ");
|
||||||
XMLEventReader xmlEventReader = AbstractParser.createEventReader(input);
|
Assert.assertEquals(XML_DOC, actualAttributeValue);
|
||||||
xmlEventReader.nextEvent();
|
|
||||||
final Object attributeValue = SAMLAttributeValueParser.getInstance().parse(xmlEventReader);
|
|
||||||
|
|
||||||
Assert.assertNull(attributeValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void parsesAttributeValueElementWithSubElements_ReturnsNull() throws Exception {
|
public void parsesAttributeValueUserTypeWithNamespace() throws Exception {
|
||||||
InputStream input = new ByteArrayInputStream(XML_DOC_WITH_NESTED_ELEMENTS.getBytes(StandardCharsets.UTF_8));
|
Object actualAttributeValue = parseAttributeValue(
|
||||||
|
"xmlns:myCustomType=\"http://my.custom.de/schema/saml/extensions\" xsi:type=\"myCustomType:Something\"",
|
||||||
|
"\n " + XML_DOC + "\n ");
|
||||||
|
Assert.assertEquals(XML_DOC, actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseAttributeValueAnyType() throws Exception {
|
||||||
|
Object actualAttributeValue = parseAttributeValue("xsi:type=\"xs:anyType\"", XML_DOC_NESTED_WITHOUT_PREFIX_AND_NAMESPACE);
|
||||||
|
Assert.assertEquals(XML_DOC_NESTED_WITHOUT_PREFIX_AND_NAMESPACE, actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parsesAttributeValueUserTypeWithSingleElements() throws Exception {
|
||||||
|
final String xmlDoc = String.format(XML_DOC_SINGLE_ELEMENT, "", "");
|
||||||
|
Object actualAttributeValue = parseAttributeValue("xsi:type=\"AddressType\"", xmlDoc);
|
||||||
|
Assert.assertEquals(xmlDoc, actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parsesAttributeValueUserTypeWithSingleElementsAndNamespace() throws Exception {
|
||||||
|
Object actualAttributeValue = parseAttributeValue(
|
||||||
|
"xmlns:myCustomType=\"http://my.custom.de/schema/saml/extensions\" xsi:type=\"myCustomType:AddressType\"",
|
||||||
|
String.format(XML_DOC_SINGLE_ELEMENT, "myCustomType:", ""));
|
||||||
|
Assert.assertEquals(String.format(XML_DOC_SINGLE_ELEMENT, "myCustomType:",
|
||||||
|
" xmlns:myCustomType=\"http://my.custom.de/schema/saml/extensions\""),
|
||||||
|
actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parsesAttributeValueUserTypeWithNestedElements() throws Exception {
|
||||||
|
Object actualAttributeValue = parseAttributeValue("xsi:type=\"AddressType\"", XML_DOC_NESTED_WITHOUT_PREFIX_AND_NAMESPACE);
|
||||||
|
Assert.assertEquals(XML_DOC_NESTED_WITHOUT_PREFIX_AND_NAMESPACE, actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parsesAttributeValueUserTypeWithNestedElementsAndNamespace() throws Exception {
|
||||||
|
Object actualAttributeValue = parseAttributeValue(
|
||||||
|
"xmlns:myCustomType=\"http://my.custom.de/schema/saml/extensions\" xsi:type=\"myCustomType:AddressType\"",
|
||||||
|
String.format(XML_DOC_NESTED_ELEMENTS, "myCustomType:", ""));
|
||||||
|
Assert.assertEquals(String.format(XML_DOC_NESTED_ELEMENTS, "myCustomType:",
|
||||||
|
" xmlns:myCustomType=\"http://my.custom.de/schema/saml/extensions\""),
|
||||||
|
actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parsesAttributeValueUserTypeWithAttributeAndInnerNamespace() throws Exception {
|
||||||
|
String xmlDocPayload = "<%1$sAddress myCustomType3:restriction=\"one-way\"%3$s><%1$sStreet>Zillestraße</%1$sStreet><%2$sHouseNumber%4$s>17"
|
||||||
|
+ "</%2$sHouseNumber><myCustomType4:ZipCode xmlns:myCustomType4=\"http://my.custom4.de/schema/saml/extensions\">10585"
|
||||||
|
+ "</myCustomType4:ZipCode><City xmlns=\"http://my.custom4.de/schema/saml/extensions\">Berlin</City></%1$sAddress>";
|
||||||
|
String namespace1 = "xmlns:myCustomType1=\"http://my.custom1.de/schema/saml/extensions\"";
|
||||||
|
String namespace2 = "xmlns:myCustomType2=\"http://my.custom2.de/schema/saml/extensions\"";
|
||||||
|
String namespace3 = "xmlns:myCustomType3=\"http://my.custom3.de/schema/saml/extensions\"";
|
||||||
|
String namespace4 = "xmlns:myCustomType4=\"http://my.custom4.de/schema/saml/extensions\"";
|
||||||
|
Object actualAttributeValue = parseAttributeValue(
|
||||||
|
namespace1 + " " + namespace2 + " " + namespace3 + " " + namespace4 + " xsi:type=\"myCustomType1:AddressType\"",
|
||||||
|
String.format(xmlDocPayload, "myCustomType1:", "myCustomType2:", "", ""));
|
||||||
|
Assert.assertEquals(String.format(xmlDocPayload, "myCustomType1:", "myCustomType2:", " " + namespace3 + " " + namespace1, " " + namespace2),
|
||||||
|
actualAttributeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object parseAttributeValue(String namespaceAndType, String payload) throws Exception {
|
||||||
|
InputStream input = new ByteArrayInputStream(asAttribute(namespaceAndType, payload).getBytes(StandardCharsets.UTF_8));
|
||||||
XMLEventReader xmlEventReader = AbstractParser.createEventReader(input);
|
XMLEventReader xmlEventReader = AbstractParser.createEventReader(input);
|
||||||
xmlEventReader.nextEvent();
|
xmlEventReader.nextEvent();
|
||||||
final Object attributeValue = SAMLAttributeValueParser.getInstance().parse(xmlEventReader);
|
Object attributeValue = SAMLAttributeValueParser.getInstance().parse(xmlEventReader);
|
||||||
|
|
||||||
Assert.assertNull(attributeValue);
|
XMLEvent nextXmlEvent = xmlEventReader.nextEvent();
|
||||||
|
Assert.assertTrue(nextXmlEvent.isEndElement());
|
||||||
|
final String nextName = nextXmlEvent.asEndElement().getName().getLocalPart();
|
||||||
|
Assert.assertTrue("Attribute".equals(nextName) || "AttributeValue".equals(nextName)); // both are valid
|
||||||
|
|
||||||
|
return attributeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asAttribute(String namespaceAndType, String payload) {
|
||||||
|
return "<saml2:Attribute xmlns:saml2=\"urn:oasis:names:tc:SAML:2.0:assertion\"><saml2:AttributeValue xmlns:xsi=\"http://www.w3"
|
||||||
|
+ ".org/2001/XMLSchema-instance\" " + namespaceAndType + ">" + payload + "</saml2:AttributeValue></saml2:Attribute>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -756,7 +756,7 @@ public class SAMLParserTest {
|
||||||
@Test
|
@Test
|
||||||
public void testInvalidEndElement() throws Exception {
|
public void testInvalidEndElement() throws Exception {
|
||||||
thrown.expect(ParsingException.class);
|
thrown.expect(ParsingException.class);
|
||||||
// see KEYCLOAK-7444
|
// see KEYCLOAK-7444
|
||||||
thrown.expectMessage(containsString("NameIDFormat"));
|
thrown.expectMessage(containsString("NameIDFormat"));
|
||||||
|
|
||||||
assertParsed("saml20-entity-descriptor-idp-invalid-end-element.xml", EntityDescriptorType.class);
|
assertParsed("saml20-entity-descriptor-idp-invalid-end-element.xml", EntityDescriptorType.class);
|
||||||
|
@ -1003,7 +1003,7 @@ public class SAMLParserTest {
|
||||||
AssertionType assertion = assertParsed("saml20-assertion-example.xml", AssertionType.class);
|
AssertionType assertion = assertParsed("saml20-assertion-example.xml", AssertionType.class);
|
||||||
|
|
||||||
AttributeStatementType attributeStatementType = assertion.getAttributeStatements().iterator().next();
|
AttributeStatementType attributeStatementType = assertion.getAttributeStatements().iterator().next();
|
||||||
assertThat(attributeStatementType.getAttributes(), hasSize(9));
|
assertThat(attributeStatementType.getAttributes(), hasSize(12));
|
||||||
|
|
||||||
for (AttributeStatementType.ASTChoiceType choiceType: attributeStatementType.getAttributes()) {
|
for (AttributeStatementType.ASTChoiceType choiceType: attributeStatementType.getAttributes()) {
|
||||||
AttributeType attr = choiceType.getAttribute();
|
AttributeType attr = choiceType.getAttribute();
|
||||||
|
@ -1012,7 +1012,7 @@ public class SAMLParserTest {
|
||||||
// test selected attributes
|
// test selected attributes
|
||||||
switch (attrName) {
|
switch (attrName) {
|
||||||
case "portal_id":
|
case "portal_id":
|
||||||
assertEquals(value, "060D00000000SHZ");
|
assertEquals("060D00000000SHZ", value);
|
||||||
break;
|
break;
|
||||||
case "organization_id":
|
case "organization_id":
|
||||||
assertThat(value, instanceOf(String.class));
|
assertThat(value, instanceOf(String.class));
|
||||||
|
@ -1028,6 +1028,9 @@ public class SAMLParserTest {
|
||||||
case "anytype_no_xml_test":
|
case "anytype_no_xml_test":
|
||||||
assertThat(value, is((Object) "value_no_xml"));
|
assertThat(value, is((Object) "value_no_xml"));
|
||||||
break;
|
break;
|
||||||
|
case "anytype_xml_fragment":
|
||||||
|
assertThat(value, is((Object) "<elem1>Foo</elem1><elem2>Bar</elem2>"));
|
||||||
|
break;
|
||||||
case "logouturl":
|
case "logouturl":
|
||||||
assertThat(value, is((Object) "http://www.salesforce.com/security/del_auth/SsoLogoutPage.html"));
|
assertThat(value, is((Object) "http://www.salesforce.com/security/del_auth/SsoLogoutPage.html"));
|
||||||
break;
|
break;
|
||||||
|
@ -1037,6 +1040,12 @@ public class SAMLParserTest {
|
||||||
case "status":
|
case "status":
|
||||||
assertThat(value, is((Object) "<status><code><status>XYZ</status></code></status>"));
|
assertThat(value, is((Object) "<status><code><status>XYZ</status></code></status>"));
|
||||||
break;
|
break;
|
||||||
|
case "userDefined":
|
||||||
|
assertThat(value, is((Object) "<A><B>Foo</B><C>Bar</C></A>"));
|
||||||
|
break;
|
||||||
|
case "userDefinedFragmentWithNamespace":
|
||||||
|
assertThat(value, is((Object) "<myPrefix:B xmlns:myPrefix=\"urn:myNamespace\">Foo</myPrefix:B><myPrefix:C xmlns:myPrefix=\"urn:myNamespace\">Bar</myPrefix:C>"));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1130,7 +1139,7 @@ public class SAMLParserTest {
|
||||||
assertThat(ac.getSequence(), notNullValue());
|
assertThat(ac.getSequence(), notNullValue());
|
||||||
|
|
||||||
assertThat(ac.getSequence().getClassRef().getValue(), is(JBossSAMLURIConstants.AC_UNSPECIFIED.getUri()));
|
assertThat(ac.getSequence().getClassRef().getValue(), is(JBossSAMLURIConstants.AC_UNSPECIFIED.getUri()));
|
||||||
|
|
||||||
assertThat(ac.getSequence(), notNullValue());
|
assertThat(ac.getSequence(), notNullValue());
|
||||||
assertThat(ac.getSequence().getAuthnContextDecl(), notNullValue());
|
assertThat(ac.getSequence().getAuthnContextDecl(), notNullValue());
|
||||||
assertThat(ac.getSequence().getAuthnContextDecl().getValue(), instanceOf(Element.class));
|
assertThat(ac.getSequence().getAuthnContextDecl().getValue(), instanceOf(Element.class));
|
||||||
|
|
|
@ -118,6 +118,13 @@
|
||||||
<saml:AttributeValue>value_no_xml</saml:AttributeValue>
|
<saml:AttributeValue>value_no_xml</saml:AttributeValue>
|
||||||
</saml:Attribute>
|
</saml:Attribute>
|
||||||
|
|
||||||
|
<saml:Attribute Name="anytype_xml_fragment">
|
||||||
|
<saml:AttributeValue xsi:type="xs:anyType">
|
||||||
|
<elem1>Foo</elem1>
|
||||||
|
<elem2>Bar</elem2>
|
||||||
|
</saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
|
||||||
<saml:Attribute Name="ssostartpage"
|
<saml:Attribute Name="ssostartpage"
|
||||||
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
|
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
|
||||||
|
|
||||||
|
@ -138,6 +145,13 @@
|
||||||
<saml:AttributeValue xsi:nil="true" xsi:type="xs:anyType"/>
|
<saml:AttributeValue xsi:nil="true" xsi:type="xs:anyType"/>
|
||||||
</saml:Attribute>
|
</saml:Attribute>
|
||||||
|
|
||||||
|
<saml:Attribute Name="userDefined">
|
||||||
|
<saml:AttributeValue xsi:type="MyType"><A><B>Foo</B><C>Bar</C></A></saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
|
||||||
|
<saml:Attribute Name="userDefinedFragmentWithNamespace">
|
||||||
|
<saml:AttributeValue xmlns:myPrefix="urn:myNamespace" xsi:type="myPrefix:MyType"><myPrefix:B>Foo</myPrefix:B><myPrefix:C>Bar</myPrefix:C></saml:AttributeValue>
|
||||||
|
</saml:Attribute>
|
||||||
|
|
||||||
</saml:AttributeStatement>
|
</saml:AttributeStatement>
|
||||||
</saml:Assertion>
|
</saml:Assertion>
|
||||||
|
|
|
@ -0,0 +1,290 @@
|
||||||
|
package org.keycloak.broker.saml.mappers;
|
||||||
|
|
||||||
|
import static org.keycloak.saml.common.constants.JBossSAMLURIConstants.ATTRIBUTE_FORMAT_BASIC;
|
||||||
|
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import javax.xml.XMLConstants;
|
||||||
|
import javax.xml.namespace.NamespaceContext;
|
||||||
|
import javax.xml.xpath.XPath;
|
||||||
|
import javax.xml.xpath.XPathConstants;
|
||||||
|
import javax.xml.xpath.XPathExpressionException;
|
||||||
|
import javax.xml.xpath.XPathFactory;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.broker.provider.AbstractIdentityProviderMapper;
|
||||||
|
import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||||
|
import org.keycloak.broker.saml.SAMLEndpoint;
|
||||||
|
import org.keycloak.broker.saml.SAMLIdentityProviderFactory;
|
||||||
|
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.metadata.AttributeConsumingServiceType;
|
||||||
|
import org.keycloak.dom.saml.v2.metadata.EntityDescriptorType;
|
||||||
|
import org.keycloak.dom.saml.v2.metadata.RequestedAttributeType;
|
||||||
|
import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
|
import org.keycloak.models.IdentityProviderSyncMode;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.models.UserModel;
|
||||||
|
import org.keycloak.protocol.saml.mappers.SamlMetadataDescriptorUpdater;
|
||||||
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
|
import org.keycloak.saml.common.util.DocumentUtil;
|
||||||
|
import org.keycloak.saml.common.util.StringUtil;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
|
||||||
|
public class XPathAttributeMapper extends AbstractIdentityProviderMapper implements SamlMetadataDescriptorUpdater {
|
||||||
|
|
||||||
|
public static final String[] COMPATIBLE_PROVIDERS = {SAMLIdentityProviderFactory.PROVIDER_ID};
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(XPathAttributeMapper.class);
|
||||||
|
|
||||||
|
private static final List<ProviderConfigProperty> configProperties = new ArrayList<>();
|
||||||
|
|
||||||
|
public static final String ATTRIBUTE_XPATH = "attribute.xpath";
|
||||||
|
public static final String ATTRIBUTE_NAME = "attribute.name";
|
||||||
|
public static final String ATTRIBUTE_FRIENDLY_NAME = "attribute.friendly.name";
|
||||||
|
public static final String USER_ATTRIBUTE = "user.attribute";
|
||||||
|
private static final Set<IdentityProviderSyncMode> IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values()));
|
||||||
|
|
||||||
|
private static final Pattern NAMESPACE_PATTERN = Pattern.compile("xmlns:(\\w+)=\"(.+?)\"");
|
||||||
|
|
||||||
|
private static final ThreadLocal<XPathFactory> XPATH_FACTORY = ThreadLocal.withInitial(() -> {
|
||||||
|
final XPathFactory xPathFactory = XPathFactory.newInstance();
|
||||||
|
xPathFactory.setXPathVariableResolver(variableName -> {
|
||||||
|
throw new RuntimeException("resolveVariable for variable " + variableName + " not supported");
|
||||||
|
});
|
||||||
|
xPathFactory.setXPathFunctionResolver((functionName, arity) -> {
|
||||||
|
throw new RuntimeException("resolveFunction for function " + functionName + " not supported");
|
||||||
|
});
|
||||||
|
return xPathFactory;
|
||||||
|
});
|
||||||
|
|
||||||
|
static {
|
||||||
|
ProviderConfigProperty property;
|
||||||
|
property = new ProviderConfigProperty();
|
||||||
|
property.setName(ATTRIBUTE_XPATH);
|
||||||
|
property.setLabel("Attribute XPath");
|
||||||
|
property.setHelpText("XPath expression to search for. All attributes are surrounded with a <root> element. Given prefixes "
|
||||||
|
+ "and namespaces are preserved. Example: <root><myPrefix:Person xmlns:myPrefix=\"http://my.namespace/schema\">"
|
||||||
|
+ "<myPrefix:FirstName>John</myPrefix:FirstName>...</myPrefix:Person></root> or <root>Some attribute value of anyType</root>");
|
||||||
|
property.setType(ProviderConfigProperty.STRING_TYPE);
|
||||||
|
configProperties.add(property);
|
||||||
|
property = new ProviderConfigProperty();
|
||||||
|
property.setName(ATTRIBUTE_NAME);
|
||||||
|
property.setLabel("Attribute Name");
|
||||||
|
property.setHelpText("Name of attribute to search for in assertion and apply XPath. You can leave this blank to try to apply XPath to all attributes or specify a friendly name instead.");
|
||||||
|
property.setType(ProviderConfigProperty.STRING_TYPE);
|
||||||
|
configProperties.add(property);
|
||||||
|
property = new ProviderConfigProperty();
|
||||||
|
property.setName(ATTRIBUTE_FRIENDLY_NAME);
|
||||||
|
property.setLabel("Friendly Name");
|
||||||
|
property.setHelpText("Friendly name of attribute to search for in assertion. You can leave this blank to try to apply XPath to all attributes or specify a name instead.");
|
||||||
|
property.setType(ProviderConfigProperty.STRING_TYPE);
|
||||||
|
configProperties.add(property);
|
||||||
|
property = new ProviderConfigProperty();
|
||||||
|
property.setName(USER_ATTRIBUTE);
|
||||||
|
property.setLabel("User Attribute Name");
|
||||||
|
property.setHelpText("User attribute name to store XPath value. Use " + UserModel.EMAIL + ", " + UserModel.FIRST_NAME + ", and " + UserModel.LAST_NAME + " for e-mail, first and last name, respectively.");
|
||||||
|
property.setType(ProviderConfigProperty.STRING_TYPE);
|
||||||
|
configProperties.add(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String PROVIDER_ID = "saml-xpath-attribute-idp-mapper";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) {
|
||||||
|
return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ProviderConfigProperty> getConfigProperties() {
|
||||||
|
return configProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getId() {
|
||||||
|
return PROVIDER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getCompatibleProviders() {
|
||||||
|
return COMPATIBLE_PROVIDERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDisplayCategory() {
|
||||||
|
return "Attribute Importer";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDisplayType() {
|
||||||
|
return "XPath Attribute Importer";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
|
||||||
|
String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE);
|
||||||
|
if (StringUtil.isNullOrEmpty(attribute)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String attributeName = getAttributeNameFromMapperModel(mapperModel);
|
||||||
|
String attributeXPath = mapperModel.getConfig().get(ATTRIBUTE_XPATH);
|
||||||
|
|
||||||
|
List<String> attributeValuesInContext = findAttributeValuesInContext(attributeName, attributeXPath, context);
|
||||||
|
if (!attributeValuesInContext.isEmpty()) {
|
||||||
|
context.setUserAttribute(attribute, attributeValuesInContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getAttributeNameFromMapperModel(IdentityProviderMapperModel mapperModel) {
|
||||||
|
String attributeName = mapperModel.getConfig().get(ATTRIBUTE_NAME);
|
||||||
|
if (attributeName == null) {
|
||||||
|
attributeName = mapperModel.getConfig().get(ATTRIBUTE_FRIENDLY_NAME);
|
||||||
|
}
|
||||||
|
return attributeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Predicate<AttributeStatementType.ASTChoiceType> elementWith(String attributeName) {
|
||||||
|
return attributeType -> {
|
||||||
|
AttributeType attribute = attributeType.getAttribute();
|
||||||
|
return attributeName == null
|
||||||
|
|| Objects.equals(attribute.getName(), attributeName)
|
||||||
|
|| Objects.equals(attribute.getFriendlyName(), attributeName);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Function<String, Object> applyXPath(String attributeXPath) {
|
||||||
|
return xml -> {
|
||||||
|
try {
|
||||||
|
LOGGER.tracef("Trying to parse: %s", xml);
|
||||||
|
|
||||||
|
Matcher namespaceMatcher = NAMESPACE_PATTERN.matcher(xml);
|
||||||
|
Map<String, String> namespaces = new HashMap<>();
|
||||||
|
Map<String, String> prefixes = new HashMap<>();
|
||||||
|
while (namespaceMatcher.find()) {
|
||||||
|
namespaces.put(namespaceMatcher.group(1), namespaceMatcher.group(2));
|
||||||
|
prefixes.put(namespaceMatcher.group(2), namespaceMatcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
XPath xPath = XPATH_FACTORY.get().newXPath();
|
||||||
|
xPath.setNamespaceContext(new NamespaceContext() {
|
||||||
|
@Override
|
||||||
|
public String getNamespaceURI(String prefix) {
|
||||||
|
if (namespaces.containsKey(prefix)) {
|
||||||
|
return namespaces.get(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLConstants.NULL_NS_URI;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPrefix(String namespaceURI) {
|
||||||
|
if (prefixes.containsKey(namespaceURI)) {
|
||||||
|
return prefixes.get(namespaceURI);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Iterator<String> getPrefixes(String namespaceURI) {
|
||||||
|
List<String> list = new ArrayList<>();
|
||||||
|
if (prefixes.containsKey(namespaceURI)) {
|
||||||
|
list.add(prefixes.get(namespaceURI));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.iterator();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Document document = DocumentUtil.getDocument(new StringReader(xml));
|
||||||
|
return xPath.compile(attributeXPath).evaluate(document, XPathConstants.STRING);
|
||||||
|
} catch (XPathExpressionException e) {
|
||||||
|
LOGGER.warn("Unparsable element will be ignored", e);
|
||||||
|
return "";
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Could not parse xml element", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> findAttributeValuesInContext(String attributeName, String attributeXPath, BrokeredIdentityContext context) {
|
||||||
|
AssertionType assertion = (AssertionType) context.getContextData().get(SAMLEndpoint.SAML_ASSERTION);
|
||||||
|
|
||||||
|
return assertion.getAttributeStatements().stream()
|
||||||
|
.map(AttributeStatementType::getAttributes)
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.filter(elementWith(attributeName))
|
||||||
|
.map(AttributeStatementType.ASTChoiceType::getAttribute)
|
||||||
|
.map(AttributeType::getAttributeValue)
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(Object::toString)
|
||||||
|
.map(s -> "<root>" + s + "</root>")
|
||||||
|
.map(applyXPath(attributeXPath))
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(Object::toString)
|
||||||
|
.filter(x -> !x.isEmpty())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
|
||||||
|
String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE);
|
||||||
|
if (StringUtil.isNullOrEmpty(attribute)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String attributeName = getAttributeNameFromMapperModel(mapperModel);
|
||||||
|
String attributeXPath = mapperModel.getConfig().get(ATTRIBUTE_XPATH);
|
||||||
|
List<String> attributeValuesInContext = findAttributeValuesInContext(attributeName, attributeXPath, context);
|
||||||
|
if (!attributeValuesInContext.isEmpty()) {
|
||||||
|
user.setAttribute(attribute, attributeValuesInContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getHelpText() {
|
||||||
|
return "Extract text of a saml attribute via XPath expression and import into the specified user property or attribute.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISpMetadataAttributeProvider interface
|
||||||
|
@Override
|
||||||
|
public void updateMetadata(IdentityProviderMapperModel mapperModel, EntityDescriptorType entityDescriptor) {
|
||||||
|
RequestedAttributeType requestedAttribute = new RequestedAttributeType(mapperModel.getConfig().get(XPathAttributeMapper.ATTRIBUTE_NAME));
|
||||||
|
requestedAttribute.setIsRequired(null);
|
||||||
|
requestedAttribute.setNameFormat(ATTRIBUTE_FORMAT_BASIC.get());
|
||||||
|
|
||||||
|
String attributeFriendlyName = mapperModel.getConfig().get(UserAttributeMapper.ATTRIBUTE_FRIENDLY_NAME);
|
||||||
|
if (attributeFriendlyName != null && attributeFriendlyName.length() > 0)
|
||||||
|
requestedAttribute.setFriendlyName(attributeFriendlyName);
|
||||||
|
|
||||||
|
// Add the requestedAttribute item to any AttributeConsumingServices
|
||||||
|
for (EntityDescriptorType.EDTChoiceType choiceType: entityDescriptor.getChoiceType()) {
|
||||||
|
List<EntityDescriptorType.EDTDescriptorChoiceType> descriptors = choiceType.getDescriptors();
|
||||||
|
|
||||||
|
if (descriptors != null) {
|
||||||
|
for (EntityDescriptorType.EDTDescriptorChoiceType descriptor: descriptors) {
|
||||||
|
if (descriptor.getSpDescriptor() != null && descriptor.getSpDescriptor().getAttributeConsumingService() != null) {
|
||||||
|
for (AttributeConsumingServiceType attributeConsumingService: descriptor.getSpDescriptor().getAttributeConsumingService())
|
||||||
|
{
|
||||||
|
attributeConsumingService.addRequestedAttribute(requestedAttribute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ org.keycloak.broker.oidc.mappers.UsernameTemplateMapper
|
||||||
org.keycloak.broker.saml.mappers.AdvancedAttributeToRoleMapper
|
org.keycloak.broker.saml.mappers.AdvancedAttributeToRoleMapper
|
||||||
org.keycloak.broker.saml.mappers.AttributeToRoleMapper
|
org.keycloak.broker.saml.mappers.AttributeToRoleMapper
|
||||||
org.keycloak.broker.saml.mappers.UserAttributeMapper
|
org.keycloak.broker.saml.mappers.UserAttributeMapper
|
||||||
|
org.keycloak.broker.saml.mappers.XPathAttributeMapper
|
||||||
org.keycloak.broker.saml.mappers.UsernameTemplateMapper
|
org.keycloak.broker.saml.mappers.UsernameTemplateMapper
|
||||||
org.keycloak.social.facebook.FacebookUserAttributeMapper
|
org.keycloak.social.facebook.FacebookUserAttributeMapper
|
||||||
org.keycloak.social.github.GitHubUserAttributeMapper
|
org.keycloak.social.github.GitHubUserAttributeMapper
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
package org.keycloak.test.broker.saml;
|
||||||
|
|
||||||
|
import static org.hamcrest.CoreMatchers.allOf;
|
||||||
|
import static org.hamcrest.CoreMatchers.containsString;
|
||||||
|
import static org.hamcrest.CoreMatchers.instanceOf;
|
||||||
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
|
import static org.hamcrest.MatcherAssert.assertThat;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.broker.provider.BrokeredIdentityContext;
|
||||||
|
import org.keycloak.broker.saml.SAMLEndpoint;
|
||||||
|
import org.keycloak.broker.saml.mappers.XPathAttributeMapper;
|
||||||
|
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.NameIDType;
|
||||||
|
import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
|
import org.keycloak.saml.common.exceptions.ParsingException;
|
||||||
|
import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil;
|
||||||
|
|
||||||
|
public class XPathAttributeMapperTest {
|
||||||
|
|
||||||
|
private static final String ATTRIBUTE_NAME = "attributeName";
|
||||||
|
private static final String USER_ATTRIBUTE_NAME_FOR_TEST = "email";
|
||||||
|
private static final String XPATH_FOR_TEST = "//*[local-name()='Street']";
|
||||||
|
private static final String EXPECTED_RESULT = "Zillestraße";
|
||||||
|
|
||||||
|
private static final String XML_FRAGMENT =
|
||||||
|
"<Street>Zillestraße</Street><HouseNumber>17</HouseNumber><ZipCode>10585</ZipCode><City>Berlin</City><Country>DE</Country>";
|
||||||
|
|
||||||
|
private static final String XML_WITH_NAMESPACE =
|
||||||
|
"<myPrefix:Address xmlns:myPrefix=\"http://my.custom.de/schema/saml/extensions\"><myPrefix:Street>Zillestraße</myPrefix:Street>"
|
||||||
|
+ "<myPrefix:HouseNumber>17</myPrefix:HouseNumber><myPrefix:ZipCode>10585</myPrefix:ZipCode>"
|
||||||
|
+ "<myPrefix:City>Berlin</myPrefix:City><myPrefix:Country>DE</myPrefix:Country></myPrefix:Address>";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidXpath() {
|
||||||
|
assertNull(testMapping(XML_FRAGMENT, "//"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidXml() {
|
||||||
|
RuntimeException actualException =
|
||||||
|
assertThrows(RuntimeException.class, () -> testMapping("<Open>Foo</Close>", "//*"));
|
||||||
|
assertThat(actualException.getCause(), instanceOf(ParsingException.class));
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> testMapping(XML_WITH_NAMESPACE, "//*[local-name()=$street]"));
|
||||||
|
assertNull(testMapping(XML_WITH_NAMESPACE, "//*[local-name()=myPrefix:add(1,2)]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNotFound() {
|
||||||
|
assertNull(testMapping(XML_FRAGMENT, "//*[local-name()='Unknown']"));
|
||||||
|
assertNull(testMapping(XML_FRAGMENT, "//unknownPrefix:Street"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSuccess_Value() {
|
||||||
|
assertThat(testMapping(EXPECTED_RESULT, "//*"), is(EXPECTED_RESULT));
|
||||||
|
assertThat(testMapping(EXPECTED_RESULT, "/root"), is(EXPECTED_RESULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSuccess_XmlFragment() {
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, XPATH_FOR_TEST), is(EXPECTED_RESULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSuccess_XmlWithNamespace() {
|
||||||
|
assertThat(testMapping(XML_WITH_NAMESPACE, XPATH_FOR_TEST), is(EXPECTED_RESULT));
|
||||||
|
assertThat(testMapping(XML_WITH_NAMESPACE, "//myPrefix:Street"), is(EXPECTED_RESULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSuccess_FindAllElements() {
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, "/"), allOf(containsString(EXPECTED_RESULT), containsString("Berlin")));
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, "//*"), allOf(containsString(EXPECTED_RESULT), containsString("Berlin")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUserAttributeNames() {
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, XPATH_FOR_TEST, "firstName"), is(EXPECTED_RESULT));
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, XPATH_FOR_TEST, "lastName"), is(EXPECTED_RESULT));
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, XPATH_FOR_TEST, "userAttribute"), is(EXPECTED_RESULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAttributeNames() {
|
||||||
|
assertNull(testMapping(XML_FRAGMENT, XPATH_FOR_TEST, USER_ATTRIBUTE_NAME_FOR_TEST, ATTRIBUTE_NAME + "x"));
|
||||||
|
assertThat(testMapping(XML_FRAGMENT, XPATH_FOR_TEST, USER_ATTRIBUTE_NAME_FOR_TEST, null), is(EXPECTED_RESULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String testMapping(String attributeValue, String xpath) {
|
||||||
|
return testMapping(attributeValue, xpath, USER_ATTRIBUTE_NAME_FOR_TEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String testMapping(String attributeValue, String xpath, String attribute) {
|
||||||
|
return testMapping(attributeValue, xpath, attribute, ATTRIBUTE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String testMapping(String attributeValue, String xpath, String attribute, String attributeNameToSearch) {
|
||||||
|
IdentityProviderMapperModel mapperModel = new IdentityProviderMapperModel();
|
||||||
|
Map<String, String> config = new HashMap<>();
|
||||||
|
mapperModel.setConfig(config);
|
||||||
|
config.put(XPathAttributeMapper.ATTRIBUTE_NAME, attributeNameToSearch);
|
||||||
|
config.put(XPathAttributeMapper.USER_ATTRIBUTE, attribute);
|
||||||
|
config.put(XPathAttributeMapper.ATTRIBUTE_XPATH, xpath);
|
||||||
|
BrokeredIdentityContext context = new BrokeredIdentityContext("brokeredIdentityContext");
|
||||||
|
AssertionType assertion = AssertionUtil.createAssertion("assertionId", NameIDType.deserializeFromString("nameIDType"));
|
||||||
|
AttributeStatementType statement = new AttributeStatementType();
|
||||||
|
assertion.addStatement(statement);
|
||||||
|
AttributeType attributeType = new AttributeType(ATTRIBUTE_NAME);
|
||||||
|
attributeType.addAttributeValue(attributeValue);
|
||||||
|
statement.addAttribute(new AttributeStatementType.ASTChoiceType(attributeType));
|
||||||
|
AttributeType otherAttributeType = new AttributeType("Some other String");
|
||||||
|
otherAttributeType.addAttributeValue("Foobar");
|
||||||
|
statement.addAttribute(new AttributeStatementType.ASTChoiceType(otherAttributeType));
|
||||||
|
AttributeType booleanAttributeType = new AttributeType("Some boolean");
|
||||||
|
booleanAttributeType.addAttributeValue(true);
|
||||||
|
statement.addAttribute(new AttributeStatementType.ASTChoiceType(booleanAttributeType));
|
||||||
|
AttributeType longAttributeType = new AttributeType("Some long");
|
||||||
|
longAttributeType.addAttributeValue(123L);
|
||||||
|
statement.addAttribute(new AttributeStatementType.ASTChoiceType(longAttributeType));
|
||||||
|
context.getContextData().put(SAMLEndpoint.SAML_ASSERTION, assertion);
|
||||||
|
new XPathAttributeMapper().preprocessFederatedIdentity(null, null, mapperModel, context);
|
||||||
|
|
||||||
|
Object userAttributes = context.getContextData().get("user.attributes." + attribute);
|
||||||
|
return userAttributes == null ? null : ((List<?>) userAttributes).get(0).toString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -572,7 +572,7 @@ public class IdentityProviderTest extends AbstractAdminTest {
|
||||||
create(createRep("saml", "saml"));
|
create(createRep("saml", "saml"));
|
||||||
provider = realm.identityProviders().get("saml");
|
provider = realm.identityProviders().get("saml");
|
||||||
mapperTypes = provider.getMapperTypes();
|
mapperTypes = provider.getMapperTypes();
|
||||||
assertMapperTypes(mapperTypes, "saml-user-attribute-idp-mapper", "saml-role-idp-mapper", "saml-username-idp-mapper", "saml-advanced-role-idp-mapper");
|
assertMapperTypes(mapperTypes, "saml-user-attribute-idp-mapper", "saml-role-idp-mapper", "saml-username-idp-mapper", "saml-advanced-role-idp-mapper", "saml-xpath-attribute-idp-mapper");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void assertMapperTypes(Map<String, IdentityProviderMapperTypeRepresentation> mapperTypes, String ... mapperIds) {
|
private void assertMapperTypes(Map<String, IdentityProviderMapperTypeRepresentation> mapperTypes, String ... mapperIds) {
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
package org.keycloak.testsuite.broker;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.admin.client.resource.RealmResource;
|
||||||
|
import org.keycloak.broker.saml.mappers.UserAttributeMapper;
|
||||||
|
import org.keycloak.broker.saml.mappers.XPathAttributeMapper;
|
||||||
|
import org.keycloak.dom.saml.v2.protocol.AuthnRequestType;
|
||||||
|
import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
|
import org.keycloak.protocol.saml.mappers.AttributeStatementHelper;
|
||||||
|
import org.keycloak.protocol.saml.mappers.HardcodedAttributeMapper;
|
||||||
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
|
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
|
||||||
|
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
import org.keycloak.saml.common.util.DocumentUtil;
|
||||||
|
import org.keycloak.saml.processing.api.saml.v2.request.SAML2Request;
|
||||||
|
import org.keycloak.testsuite.saml.AbstractSamlTest;
|
||||||
|
import org.keycloak.testsuite.util.SamlClient;
|
||||||
|
import org.keycloak.testsuite.util.SamlClientBuilder;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import static org.hamcrest.CoreMatchers.equalTo;
|
||||||
|
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_SAML_ALIAS;
|
||||||
|
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for the {@link XPathAttributeMapper}.
|
||||||
|
* Will add an extra attribute with an XML string to the provider's response,
|
||||||
|
* extracting it using the {@link org.keycloak.saml.processing.core.parsers.saml.assertion.SAMLAttributeValueParser},
|
||||||
|
* and parsing it using {@link XPathAttributeMapper}, finally ending up in a user attribute in the database.
|
||||||
|
*
|
||||||
|
* This contains only the happy path. Have a look at <code>org.keycloak.test.broker.saml.XPathAttributeMapperTest</code>
|
||||||
|
* for unit style tests and handling parsing errors.
|
||||||
|
*/
|
||||||
|
public class KcSamlXPathAttributeMapperTest extends AbstractInitializedBaseBrokerTest {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeBrokerTest() {
|
||||||
|
super.beforeBrokerTest();
|
||||||
|
|
||||||
|
RealmResource realm = adminClient.realm(bc.providerRealmName());
|
||||||
|
ProtocolMapperRepresentation protocolMapper = new ProtocolMapperRepresentation();
|
||||||
|
protocolMapper.setProtocol("saml");
|
||||||
|
protocolMapper.setName("Hardcoded XML");
|
||||||
|
protocolMapper.setProtocolMapper(HardcodedAttributeMapper.PROVIDER_ID);
|
||||||
|
protocolMapper.getConfig().put(HardcodedAttributeMapper.ATTRIBUTE_VALUE,
|
||||||
|
"<firstName>Theo</firstName><lastName>Tester</lastName><email>test@example.org</email><xml-output>Some random text</xml-output>"
|
||||||
|
);
|
||||||
|
protocolMapper.getConfig().put(AttributeStatementHelper.FRIENDLY_NAME, "xml-friendlyName");
|
||||||
|
protocolMapper.getConfig().put(AttributeStatementHelper.SAML_ATTRIBUTE_NAME, "xml-name");
|
||||||
|
protocolMapper.getConfig().put(AttributeStatementHelper.SAML_ATTRIBUTE_NAMEFORMAT, AttributeStatementHelper.BASIC);
|
||||||
|
|
||||||
|
ClientRepresentation clientRepresentation = realm.clients().findByClientId(bc.getIDPClientIdInProviderRealm())
|
||||||
|
.get(0);
|
||||||
|
realm.clients().get(clientRepresentation.getId()).getProtocolMappers().createMapper(protocolMapper).close();
|
||||||
|
|
||||||
|
addXpathMapper("firstName");
|
||||||
|
addXpathMapper("lastName");
|
||||||
|
addXpathMapper("email");
|
||||||
|
addXpathMapper("xml-output");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addXpathMapper(String field) {
|
||||||
|
IdentityProviderMapperRepresentation xpathMapper = new IdentityProviderMapperRepresentation();
|
||||||
|
xpathMapper.setName("xpath-mapper-" + field);
|
||||||
|
xpathMapper.setIdentityProviderMapper(XPathAttributeMapper.PROVIDER_ID);
|
||||||
|
xpathMapper.setIdentityProviderAlias(IDP_SAML_ALIAS);
|
||||||
|
xpathMapper.setConfig(ImmutableMap.<String, String>builder()
|
||||||
|
.put(IdentityProviderMapperModel.SYNC_MODE, "INHERIT")
|
||||||
|
.put(XPathAttributeMapper.ATTRIBUTE_FRIENDLY_NAME, "xml-friendlyName")
|
||||||
|
.put(XPathAttributeMapper.ATTRIBUTE_XPATH, "//*[local-name()='" + field + "']")
|
||||||
|
.put(XPathAttributeMapper.USER_ATTRIBUTE, field)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
identityProviderResource
|
||||||
|
.addMapper(xpathMapper).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected BrokerConfiguration getBrokerConfiguration() {
|
||||||
|
return new KcSamlBrokerConfiguration(false) {
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testXPathAttributeMapper() throws Exception {
|
||||||
|
AuthnRequestType loginRep = SamlClient.createLoginRequestDocument(AbstractSamlTest.SAML_CLIENT_ID_SALES_POST, getConsumerRoot() + "/sales-post/saml", null);
|
||||||
|
|
||||||
|
Document doc = SAML2Request.convert(loginRep);
|
||||||
|
|
||||||
|
new SamlClientBuilder()
|
||||||
|
.authnRequest(getConsumerSamlEndpoint(bc.consumerRealmName()), doc, SamlClient.Binding.POST).build() // Request to consumer IdP
|
||||||
|
.login().idp(bc.getIDPAlias()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(SamlClient.Binding.POST) // AuthnRequest to producer IdP
|
||||||
|
.targetAttributeSamlRequest()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
.login().user(bc.getUserLogin(), bc.getUserPassword()).build()
|
||||||
|
|
||||||
|
.processSamlResponse(SamlClient.Binding.POST) // Response from producer IdP
|
||||||
|
.transformDocument(document -> {
|
||||||
|
// this XML should contain the hardcoded extra attribute
|
||||||
|
log.infof("Document: %s", DocumentUtil.asString(document));
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
|
||||||
|
.followOneRedirect()
|
||||||
|
.followOneRedirect()
|
||||||
|
|
||||||
|
.getSamlResponse(SamlClient.Binding.POST);
|
||||||
|
|
||||||
|
RealmResource realm = adminClient.realm(bc.consumerRealmName());
|
||||||
|
|
||||||
|
UserRepresentation user = realm.users().search(bc.getUserLogin()).get(0);
|
||||||
|
Assert.assertThat(user.getFirstName(), equalTo("Theo"));
|
||||||
|
Assert.assertThat(user.getLastName(), equalTo("Tester"));
|
||||||
|
Assert.assertThat(user.getEmail(), equalTo("test@example.org"));
|
||||||
|
Assert.assertThat(user.getAttributes().get("xml-output"), equalTo(Collections.singletonList("Some random text")));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue