Tips and tricks with ADFS claims rules

Rory Braybrook
The new control plane
12 min readJan 11, 2021

--

I’ve answered hundreds of questions around Active Directory Federation Services (ADFS) claims rules in the old MDSN forum and this MSDN forum and in my previous blog and on stackoverflow so I thought I would consolidate some of the rules here.

I “saved” some of the old TechNet articles here so have a read there first. Note there are some good examples of regex there.

Many thanks to all the other contributors (too many to mention) whose wisdom is reflected here 😃

There’s no particular order to these.

This is a work-in-progress as I will keep adding to this as I find relevant examples.

Note:

I have copied these rules over from a number of places so the syntax may not be 100% correct but it will be enough to get you going! In particular, watch out for the “. They need to be “straight” not “slanted” e.g.

“http://contoso.com/P1"

Refer this.

If-then-else

There isn’t really an if-then-else construct but you can do this:

Problem

There are two claims; P1 and P2.

If P1 is present, use P1 otherwise use P2.

e.g. use “email” if present, otherwise use “upn”.

Solution

Issue P1 if there is one.

c1:[Type == “http://contoso.com/P1"] 
=> issue(Type=”http://contoso.com/P1", Value=c1.value);

Use a “NOT EXISTS” rule.

NOT EXISTS([Type == “http://contoso.com/P1"])
=> add(Type = “http://contoso.com/noP1", Value = “No”);

The value is not important. We just need a claim that we can use in the next rule.

c1:[Type == “http://contoso.com/noP1"] 
&& c2:[Type == “http://contoso.com/P2"]
=> issue(Type=”http://contoso.com/P2", Value=c2.value);

Equality

There isn’t really a way to compare two claims but you can do this:

Problem

Compare two claims P1 and P2 and see if they are equal.

If they are, set “isEqual” to “Yes”.

Solution

c1:[Type == “http://contoso.com/P1"] 
&& c2:[Type == “http://contoso.com/P2"]
=> issue(Type = “http://contoso.com/isEqual", Value = RegExReplace(c1.Value, c2.Value, “Yes”));

If the value of P1 matches the values of P2, then the regex patterns will match and isEqual will be replaced with “Yes”.

Otherwise isEqual will be issued with the value of P1.

Also refer this.

Get all claims

Problem

I want all the claims available.

Solution

c:[] => issue(claim = c);

Get a unique claim per user

Problem

We want a claim that is unique per user.

Solution

Use objectGUID.

The claims rules are editable (see below under “Tips”) so you can just enter this value.

Note:

The objectGUID in AD will be in a base64 format when issued from an AD attribute store.

To get the actual GUID value, you must decode and convert it. You can use the StringProcessing custom attribute store and extend it using something like:

static private string ConvertBase64ToGuid(string myData)
{
byte[] encodeAsBytes = System.Convert.FromBase64String(myData);
string returnValue = new Guid(encodeAsBytes).ToString();
return returnValue;
}

References: Here and here.

Extending NameID format and namequalifier

Problem

We need to add the NameID format etc. from a custom rule.

Note: You could do this with a Transform rule.

Solution

Use “Properties”

c:[Type == "http://mycompany/internal/sessionid"]    
=> issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, Value = c.Value, ValueType = c.ValueType, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/format"] = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/namequalifier"] = http://xxx/adfs/services/trust, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/spnamequalifier"] = "sp_test");

References: Here.

Sending groups as claims

Problem

We want to send AD groups as claims.

Solution

These are normally sent with a claim type of “role”

There are a number of options in the claims rules for the groups i.e.

  • Token-Groups as SIDs
  • Token-Groups — Qualified by Domain Name
  • Token-Groups — Qualified by Long Domain Name
  • Token-Groups — Unqualified Names

If you have a group called “Editor” with a SID of S-1–5–21–3794324387–748717723–962058466–1466 and a domain of “company.com” (and assuming you map them all to a type of “role”) then the four different types result in:

  • …identity/claims/role = S-1–5–21–3794324387–748717723–962058466–1466
  • …identity/claims/role = company\Editor
  • …identity/claims/role = company.com\Editor
  • …identity/claims/role = Editor

References: Here

Static claims

Problem

We want to send a claim where there is no AD attribute or reference or the value is the same for all users.

Solution

Use a static claim:

=> issue(Type = "someClaim", Value = "someValue");

Convert claims rules to lower case

Problem

We want the rule to produce a lower case claim

Solution

You could do this via a custom attribute store:

String Processing Attribute Store Example

This post also shows how to convert to upper case and Base64.

There are also a few examples of the form:

c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname";, Issuer == "AD AUTHORITY"]
=> add(store = "Active Directory", types = ("temp_email"), query = ";mail;{0}", param = c.Value);
c:[Type == "temp_email"]
=> issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";, Value = RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(RegExReplace(c.Value, "a", "A"), "b", "B"), "c", "C"), "d", "D"), "e", "E"), "f", "F"), "g", "G"), "h", "H"), "i", "I"), "j", "J"), "k", "K"), "l", "L"), "m", "M"), "n", "N"), "o", "O"), "p", "P"), "q", "Q"), "r", "R"), "s", "S"), "t", "T"), "u", "U"), "v", "V"), "w", "W"), "x", "X"), "y", "Y"), "z", "Z"));

This will convert the value of “temp_email” to upper case.

Or you could do this individually using 28 custom issuance transform rules:

Rule #1

c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"]
=> add(store = "Active Directory", types = ("temp_user_id"), query = ";sAMAccountName;{0}", param = c.Value);

Note the use of “add” not “issue” here.

Rules #2–27

c:[Type == "temp_user_id"]
=> add(Type = "temp_user_id", Value = RegExReplace(c.Value, "a", "A"));

You need to create one rule for each of the alphabet letters.

Rule #28

c:[Type == "temp_user_id", Value =~ "^[^a-z]+$"]
=> issue(Type = "user_id", Value = c.Value);

References: Here.

Get all groups authenticated user belongs to

Problem

We want to return the names of all the groups that the user belongs to in the output claim.

Solution

Use the wizard.

In this example, you will get multiple claims (one for each memberOf) of type “Group”.

See “Multi-valued attribute” in Tips.

References: Here.

Remove a character from a claim

Problem

We want to remove a character (or a string) from a claim.

Solution

c:[Type == "http://adatum.com/data1holder"]
=> issue(Type = "http://adatum.com/data1", Value = RegExReplace(c.Value, "'", ""));

e.g. this searches the claim for an apostrophe and removes it (by replacing it with an empty string).

Concatenate a claim with a string

Problem

We want to add a string to a claim

Solution

c1:[Type == ("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")]
=> issue(Type = "claimtype", Value = c1.Value + "string value");

Replace domain name

Problem

We want to replace one domain name with another.

Solution

To replace xxx@somedomain with xxx@anotherdomain:

c:[Type == “http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"]
=> issue(Type = c.Type, Value = regexreplace(c.Value, “(?<user>[^\\]+)@(?<domain>.+)”, “${user}@anotherdomain”));

Using the suffix value of user’s UPN to create another claim

Problem

We want a new claim based on part of the UPN.

Solution

This rule uses the suffix value of a user’s UPN and uses that to generate a new claim called “Issuerid.”

Example: http://contoso.com/adfs/services/trust/

c:[Type == "http://schemas.xmlsoap.org/claims/UPN](http://schemas.xmlsoap.org/claims/UPN"] => issue(Type = "https://schemas.microsoft.com/ws/2008/06/identity/claims/issuerid", Value = regexreplace(c.Value, .+@(?<domain>.+), http://${domain}/adfs/services/trust/));

References: Here.

Searching for one attribute that’s linked to another

Problem

In this example, we want to find the group SID for groups that are filtered by some constraint e.g. starts with “adm_”.

The rule issues both the group name and its associated SID.

Another example would be searching for a user and then finding their associated manager.

Solution

First, get the groups, then get the associated mapping. Notice the first rule is an “add” since we don’t want to issue these claims, we just want to use them as input to the second rule.

c:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"]
=> add(store = "Active Directory", types = ("http://schemas.xmlsoap.org/claims/Group"), query = ";tokenGroups;{0}", param = c.Value)
c1:[Type == "http://schemas.xmlsoap.org/claims/Group", Value =~ "INSERT-REGEX-HERE"]
&& c2:[Type == "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == "AD AUTHORITY"]
=> issue(store = "Active Directory", types = ("https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", "http://schemas.xmlsoap.org/claims/Group"), query = "(&(name={0}));objectSid,name;{1}", param = c1.Value, param = c2.Value);

There is more on the query command in the Tips section below.

References: Here.

Using eduPerson attributes

Problem

We need to output eduPerson attributes rather that the claims rule format.

Solution

Note: eduperson attributes are of the form e.g.:

urn:oid:1.3.6.1.4.1.5923.1.1.1.6

The UPN of the user. Resembles an Email but should not be expected to be one.

The more usual form is “http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn".

c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"]
=> issue(Type = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", Value = c.Value, Properties["http://schemas.xmlsoap.org/ws/2005/05/identity/claimproperties/attributename"] = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri");

IP address range

Problem

We want our local networks users to be able to access Skype for Business Online and Outlook Online.

If there is any IP claim outside the desired range, issue ipoutsiderange claim.

Desired range:

  1. 172.23.0.0/16 (network segment)
  2. 112.34.56.78/32 (only individual IP)

Based on this:

So the range is 172.23.0.1 to 172.23.255.254.

Using regex101:

we can see the correct matches.

The regex is:

\b172\.23\.(([0-1]?[0-9]?[0-9]|[2][0-4][0-9]|25[0-5]))\.(([0-1]?[0-9]?[0-9]|[2][0-4][0-9]|25[0-4]))\b|
\b112\.34\.56\.78\b|

Solution

NOT EXISTS([Type == "http://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-forwarded-client-ip", Value =~ "(\b172\.23\.(([0-1]?[0-9]?[0-9]|[2][0-4][0-9]|25[0-5]))\.(([0-1]?[0-9]?[0-9]|[2][0-4][0-9]|25[0-4]))\b|
\b112\.34\.56\.78\b|)"])
&& EXISTS([Type == "http://schemas.microsoft.com/ws/2012/01/insidecorporatenetwork", Value == "false"])
=> issue(Type = "http://custom/ipoutsiderange", Value = "true");

References: Here.

Tips

Beware copying over claims rules when they contain groups

If one of the claims rules is a “Send Group Membership as a Claim” e.g.

c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", Value == “S-1–5–21–965288371-…-1106”, Issuer == “AD AUTHORITY”]
=> issue(Type = “http://schemas.microsoft.com/ws/2008/06/identity/claims/role", Value = “Admin”, Issuer = c.Issuer, OriginalIssuer = c.OriginalIssuer, ValueType = c.ValueType);

Notice that it has a SID and (of course) this SID is relevant to that instance of AD only.

The same group name in a different AD will have a different SID value.

So, beware, you can’t just copy these types of rules over!

Multi-valued attribute

This has the value in AD of:

value1;value2;value3

ADFS handles this by producing a new claim (of the same type) for each value.

So if you took the above and mapped them to a claim of type Values, you’ll get:

…/claim/Value = value3
…/claim/Value = value2
…/claim/Value = value1

Interestingly, it seems to display the values in reverse order.

The claims rules drop-down is editable

If you have a claims rule that is not in the drop-down, the text field is editable!

“Edit Claims Rules / Add Rule / Send LDAP Attributes as Claims”.
Don’t select the drop-down, just click in the white space of the grid.
If the box turns dark blue, click again.
Away you go — you can now enter any attribute you like.

This also works for the “Outgoing Claims Type” box.

Differences in SAML token type

Beware the difference between the WS-Federation and the SAML URI wrt. the SAML token.

WS-Fed uses the SAML 1.1 format whereas SAML uses the SAML 2.0 format.

This rule is accepted by both as the type is a “full” URI:

Type = http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress

c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == “AD AUTHORITY”]
=> issue(store = “Active Directory”, types = (“http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"), query = “;mail;{0}”, param = c.Value);

This rule is not accepted by WS-Fed as the type is not a “full” URI:

c:[Type == “http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", Issuer == “AD AUTHORITY”]
=> issue(store = “Active Directory”, types = (“EmailAddress”), query = “;mail;{0}”, param = c.Value);

type = “EmailAddress”

The error you get is:

The Federation Service encountered an error while processing the WS-Trust request.
Additional Data
Exception details:
System.ArgumentException: ID4216: The ClaimType 'EmailAddress' must be of format 'namespace'/'name'.

References: Here.

Test regex

There are a number of online regex testers. I tend to use regex101.

e.g. if you wanted a claims rule to split out the domain and user and then use them, you could use:

(?<domain>.+)\\(?<user>.+)

regex101 shows:

Expanded for the test string “domain\user1”:

This gives us two capture groups that we can use in the replace:

c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"]
=> issue(Type = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", Value = regexreplace(c.Value, "(?<domain>.+)\\(?<user>.+)", "${user}@company.com"));

“${user}@company.com” becomes “user1@company.com”.

More on the QUERY command

I cannot find the original article but luckily I saved this text some time ago!

— — — — — — — — — — — -

The Active directory attribute store is used to query Active directories in the enterprise. There is a single instance of this attribute store that is automatically created when AD FS 2.0 starts.

The Active directory attribute store validates and evaluates the query string passed to it using the QUERY keyword. This attribute store recognizes the following format of query

QUERY = <QUERY_FILTER>;<ATTRIBUTES>;<DOMAIN_NAME>\<USERNAME>

Thus the QUERY has three parts separated by two semi-colons.

The QUERY_FILTER is the query string that is executed against the directory to narrow down the directory objects.

The ATTRIBUTES is the comma separated name of attributes to be returned from the filtered directory objects.

QUERY_FILTER, ATTRIBUTES, DOMAIN_NAME and USERNAME can have parameter replacement placeholders like {0}, {1} etc which are filled with query parameter values passed to it from the program using the PARAM keyword.

The order of substitution of PARAM values is in the order in which they appear in the original attribute store query passed to the attribute store.

That is, the first PARAM value mentioned in the query is substituted for placeholder {0}, the second PARAM value is substituted for placeholder {1} and so on.

After all parameter substitutions (if applicable), the format of the resulting QUERY_FILTER is as defined in [RFC 2254]. Similarly each attribute name in the comma separated list of attributes follows the format of attribute type name as defined in section [4.2] of [RFC2252].

The DOMAIN_NAME portion of the query is used to identify and locate the domain controller (DC) to connect to for executing the LDAP query. The DOMAIN_NAME is used to locate the DC using methods described in section [7.3.6] of [MS-ADTS].

The attribute store implementation executes an LDAP query using QUERY_FILTER as the query targeted at the AD domain controller and requests the return attributes whose names are available in the ATTRIBUTES string.

The wire format of what messages are sent to the LDAP server and what messages are returned back to the caller are documented in the appropriate list of RFC’s referred in [RFC 3377].The QUERY_FILTER portion gets substituted as the value of filter as defined in section [4.5.1 Search Request] in [RFC 2251] for search request message. The ATTRIBUTES portion gets substituted as the value of attributes in section [4.5.1 Search Request] in [RFC 2251] for search request message. Note that section [3.1.1.3] of [MS-ADTS] describes Active directory usage of LDAP and conformance/non-conformance to the various LDAP RFCs.

The AD server returns a LDAP Search Result message with attribute values as defined in section [4.5.2 Search Response] in [RFC 2251]. The attribute store implementation then creates a two dimensional array of strings from the value of attributes returned from the server.

There are as many rows in the array as the number of directory objects that were filtered using the QUERY_FILTER and there are as many columns in each row as the number of attributes requested. In other words, each column in the row corresponds to one attribute value as requested in the return attribute list. This two dimensional array of strings is ultimately returned as the result of query execution from the AD attribute store.

Note that if you don’t specify the QUERY_FILTER, then we use the default filter of samAccountName={0}. In that case, you need to give at least one parameter to the query (using PARAM keyword) to substitute for samAccountName value.

— — — — — — — —

Note: Although the domain name in the query needs to be valid, I have found that you can use any user name.

Custom attribute store

If there is something you need that cannot be done with the claims rules, you can create a custom attribute store.

You can then use C# code to do pretty much anything.

An example would be changing the objectGUID attribute that is a Base64 encoded string to a string.

Issuance Authorisation Rules

Claims rules can also be used for access control (authorisation).

This is much the same as conditional access in Azure AD.

You can set up rules in the “Issuance Authorisation Rules” tab to e.g. only allow access to members of a particular group.

This topic is somewhat out of scope for this article but there’s a good reference here.

The end

I hope these examples help.

If there are any errors or other rules you think are useful, please use the comments section.

All good!

--

--

Rory Braybrook
The new control plane

NZ Microsoft Identity dude and MVP. Azure AD/B2C/ADFS/Auth0/identityserver. StackOverflow: https://bit.ly/2XU4yvJ Presentations: http://bit.ly/334ZPt5