Parsing SAML response for Active Directory Memberships

In our SAML response we can get a list of active directories that a person is part of, we intend to use this to control access and access level. The data contained in the attribute value is an array.

When I use the following syntax I get server error 500:

/saml2p:Response/saml2:Assertion/saml2:AttributeStatement/saml2:Attribute[@Name='adgroups']/saml2:AttributeValue

The image I have in my head is that we get that array into the “Roles” attribute but am not quite sure how. Once in Roles, we would have to look over the list of AD’s to see if the correct one is included.

Any advice appreciated.

Thanks,

Nick

I’m now able to get the AD group data into roles with the following syntax but I’m not sure how to parse out the individual AD names.

Also, if we do manage to parse out all the individual AD group names, should we be naming our roles the same as the AD groups?

Its not clear in documentation how to handle this case so any advice is appreciated.

/saml2p:Response/saml2:Assertion/saml2:AttributeStatement/saml2:Attribute[@Name='adgroups']/saml2:AttributeValue/text()

Thanks,

Nick

For what it is worth I found by using online XPath tools like xPather.com that it is not necessary to specify the absolute paths to the element nodes and their atomic values. It suffices with the following relative path selector syntax:

//nodename[@attribute="value"]

or more concretely:

  • ID: //saml:Attribute[@Name="accountname"]
  • Username: //saml:Attribute[@Name="name"]
  • Email: //saml:NameID

and similarly

  • //saml:Attribute[@Name="adgroups"]

The problem remains of how to tokenize the atomic value (string) returned by the attribute selector expressions. For example, //saml:Attribute[@Name="name"] returns a space-separated string John Doe as the atomic value.

Unfortunately it seems the 8.1.x gateway release that we are using does not support string functions like tokenize() that were introduced with XPath 2.0. FWIW tokenize() explodes a complex atomic value into an array of string elements given a regex pattern.

All of the following XPaths work in xPather.com but yield empty strings when specified as direct paths in Config > Security > Identity Providers > User Attribute Mapping

  • First Name: //saml:Attribute[@Name="name"]/tokenize(.,' ')[1]
  • Last Name: //saml:Attribute[@Name="name"]/tokenize(.,' ')[last()]
  • First Name: tokenize(//saml:Attribute[@Name="name"],' ')[1]
  • Last Name: tokenize(//saml:Attribute[@Name="name"],' ')[last()]
  • First Name: tokenize(normalize-space(//saml:Attribute[@Name="name"]),' ')[1]
  • Last Name: tokenize(normalize-space(//saml:Attribute[@Name="name"]),' ')[last()]
  • First Name: string-join(//saml:Attribute[@Name="name"]/tokenize(., ' ')[position() < last()],' ')
  • Last Name: string-join(//saml:Attribute[@Name="name"]/tokenize(., ' ')[position() > 1],' ')
1 Like

@jspecht if you can offer any advice on the current correct expression syntax for parsing through SAML2 responses it’d be greatly appreciated.

I’ve sent you details via DM.

Thanks,

Nick

We seem to have found a method that works using these steps:

Step 1 (Designer): Include the following syntax in a script

def filter_roles(roles_array):
	import java.util.ArrayList as ArrayList
	arr = ArrayList()
	
	for i in roles_array:
		arr.add(i.split(",")[0].replace("CN=", ""))
		
	return arr

Step 2 (Gateway): make sure the script is accessible by the gateway

Step 3 (Gateway): use the following for roles user attribute mapping, expression:

runScript("idp.filter_roles("+{idp-attributes:/saml2p:Response/saml2:Assertion/saml2:AttributeStatement/saml2:Attribute[@Name='adgroups']/saml2:AttributeValue/text()}+")")

Again, what we are using this for is to remove excess text from the AD groups portion of the SAML response, all we want is the text after the “CN=” portion.
Nick

That's correct. The Gateway uses the Java API for XML Processing (JAXP) which only supports the XPath 1.0 spec.

@nicholas.robinson - I replied to your DM. For posterity: I don't believe it is required to strip out the common name value from the full distinguished name of each ad group returned, it just might be more convenient for you when referencing a simpler string as opposed to the full DN. The only illegal security level name character is the forward-slash, and if encountered, Ignition sanitizes those by replacing with underscore characters. Just make sure that transforming the full DN to simpler strings such as the common name value by itself will still result in unique roles for your purposes (for example: if there are two separate nodes in the AD tree with CN=foo but with different parent structures (i.e. one is CN=foo,OU=bar and the other is CN=foo,OU=baz), stripping out only foo from each would result in ambiguity which previously didn't exist with the fully qualified DN).

As far as your approach goes using the runScript() expression function, I see nothing wrong with that. It might even be the most straightforward approach compared to using the Ignition expression language (or XPath) to transform the string.

@jspecht just replying here so our “solution” is clearly written. This is working for us:

Project Script:

def assign_roles(roles_array):
	"""
	Takes a 'memberOf' list from a SAML response
	returns mapped AD group names as a java array list
	Python & Java arrays are differentiated because returning a python array causes an exception
	"""
	
	import java.util.ArrayList as ArrayList
	java_array = ArrayList()

	# Extract all CN values from SAML response
	python_array = [i.split(",")[0].replace("CN=", "") for i in roles_array]	

	dev_roles = ["dev1", "dev2"]
	admin_roles = ["admin1", "admin2"]
	user_roles = ["user1" , "user2"]
	
	for CN in python_array:
		if CN in dev_roles:
			if "Developer" not in java_array:
				java_array.add("Developer")
	
	for CN in python_array:
		if CN in admin_roles:
			if "Administrator" not in java_array:
				java_array.add("Administrator")
		
	for CN in python_array:
		if CN in user_roles:
			if "User" not in java_array:
				java_array.add("User")

	return java_array

Roles Expression in the IDP:

runScript("idp.assign_roles("+{idp-attributes:/saml2p:Response/saml2:Assertion/saml2:AttributeStatement/saml2:Attribute[@Name='adgroups']/saml2:AttributeValue/text()}+")")

Our main remaining issue is that when logging in with SSO to a vision client or the designer, it takes significantly longer than for perspective or the gateway (around 13 seconds) and the message “Chunked IDP Authentication” is flashed repeatedly. We have a ticket open with the helpdesk for it [#11650] and are waiting for a solution.

We can still login, it’s just not the best user experience.

Nick

2 Likes

Updating this, we eventually moved everything to a module which reduces several dependencies including python scripting on the VM and the gateway scripting project. Deployment is also greatly simplified by using a module.

Eventually when we call it “system.net.getHostName” is passed in which when run in the gateway context, allows use to extract the site number from the DNS. This goes in the roles user mapping

runScript("system.idp.processIdpResponse("+{idp-attributes:/saml2p:Response/saml2:Assertion/saml2:AttributeStatement/saml2:Attribute[@Name='adgroups']/saml2:AttributeValue/text()}+", system.net.getHostName())")

Its not really appropriate to share all publicly but the java code is very similar to the python code. Here are some key extracts:

Common - Abstract Script Module

    @Override
    @ScriptFunction(docBundlePrefix = "AbstractScriptModule")
    public ArrayList<String> processIdpResponse(
            @ScriptArg("arg0") List<String> samlResponse,
            @ScriptArg("arg1") String hostName){

        return assignRoles(samlResponse, hostName);
    }

Gateway Script Module

    @Override
    protected ArrayList<String> assignRoles(List<String> samlResponse, String hostName) {
        // Takes the "memberOf" field from a SAML response
        // Returns an array of assigned roles

        ArrayList<String> adGroups = new ArrayList<String>();
        ArrayList<String> assignedRoles = new ArrayList<String>();
        HashMap<String, List> roleMap = new HashMap<String, List>();

        for (String member: samlResponse){
            adGroups.add(member.split(",")[0].replace("CN=", ""));
        }

        String siteNum = getSiteNum(hostName);

snip snip

        for (String role : roleMap.keySet()) {
            for (String adGroup : adGroups) {
                if (roleMap.get(role).contains(adGroup)) {
                    if (!assignedRoles.contains(role)) {
                        assignedRoles.add(role);
                    }
                }
            }
        }
        return assignedRoles;

Cheers,

Nick

1 Like

@comaife1 to answer your question, when calling SAML IDP from Ignition, Ignition out of the box does not provide a way to access the AD groups and map to roles. The way around this is to build a simple module that is called from the roles user mapping box as an expression.

Nick

@nicholas.robinson Thanks for your response.

I am not looking for parse the AD groups and map to roles - I want to extract the entire XML document returned to Ignition by the IdP. Does this also require a module?

Thanks

what are you trying to accomplish that requires having the entire XML response body?

Users will login to Ignition using the SAML2 IdP connection (SSO). The SAML2 assertion returned from the IdP will be used to retrieve data from an ERP system (resource server) via an API call. The ERP system's authorization server will issue a OAuth 2 access token in exchange for the SAML bearer assertion from Ignition.

The entire XML file contains the SAML assertion required to retrieve the OAuth 2 access token. A successful PoC was done using Postman, and now we are trying to replicate the solution in Ignition

Inside the mapping fields on an IDP provider you'd be able to extract a field and have that value returned, for example:

idp-attributes://saml:Attribute[@Name='adgroups']

You can index to anywhere in the response to grab whatever data is needed. But then to act on that data and do something else like make a API call and then do something with the response, that is module territory and is outside of what Ignition will do for you out of the box.

Nick

Thanks Nick.

What I'd like to do is extract everything from the SAML response:

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol
......
</samlp:Response>

Or everything from the SAML 2 assertion:

<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
.....
</saml:Assertion>

Thanks