An ADFS Claims Rules Adventure

[Editor's Note: This is a guest post from Steve Halligan, a Senior Premier Field Engineer.]

Notes from the Field

Customers can come up with some fairly complex requirements for access control. It can be a challenge to accommodate these requirements in an Office 365 world. The ADFS claims rule system in ADFS 2.0 UR1 provides some powerful options to implement these controls—and some limitations.

The requirements:

  1. No one shall access email via Outlook when off the corporate network
  2. Members of a specific security group may not use ActiveSync
  3. Members of a specific security group may not access OWA off the corporate network
  4. All OWA users must log in via a forms based login

It is important to note that the rule processing system always processes all rules. It is NOT a first match system. Because of this, the first rule is most always an “allow everything” rule followed by additional rules that block some access. Schematically, it could look like this:

  1. Allow everyone
  2. Block members of the group “Bigwigs” from access to OWA
  3. Allow the CEO access to OWA

When the CEO attempts to log in to OWA: Rule #1 allows him, Rule #2 blocks him, and then Rule #3 allows him. It is the state of the last matching rule that determine his final outcome. If the CIO were to attempt to log in, he would only match rules 1 and 2 and therefore would be blocked.

Requirement #1: No one shall access email via Outlook when off the corporate network

This is a fairly common requirement in a lot of enterprise Exchange environments. Security folks get a bit freaked out by the thought that a user could set up Outlook on a home machine. Meeting this challenge is pretty straightforward with ADFS 2.0 claims rules.

First, let’s review a bit how ADFS claims work in an Office 365 deployment. There are two flavors of ADFS claims requests: Active and Passive. When a claims request is created and submitted by the service (O365) it is an “active” request. This seems kind of counter-intuitive since you (the client) don’t have to do anything for these requests. The active/passive nature of the request refers to the participation of the _service_, not the client. Examples of O365 services that use active claims requests are Outlook 2007/2010 (RPC + HTTPS, EWS), Outlook 2011 for Mac (EWS), ActiveSync, Autodiscover and Lync Online.

A passive claims request is when the service sends you off to get the claim yourself. The main examples of this in O365 are OWA and Sharepoint Online. When you try to log in to OWA in O365 (and you are using federated identity) your browser is redirected to your ADFS endpoint. There you provide your credentials (either via a web form, basic authentication pop-up or integrated auth.), get your token and then return to OWA with your token in hand.

Back to the issue at hand: Blocking Outlook when client is not connected to the corporate network. To translate that into ADFS speak—We need to block active ADFS claims for the RPC+HTTPS and EWS services if the IP address doesn’t match a known set of corporate addresses.

Logically, the rule will look like this:

If {
     {ClientApplication is RPC
     Or
     ClientApplication is EWS}
     AND
     ClaimType is Active
     AND
     ClientIPAddress is not in <corporate ip address list>}
THEN DENY THE CLAIM

Now let’s translate that to ADFS Claim Rule language:

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy"]) &&
exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path", Value == "/adfs/services/trust/2005/usernamemixed"]) &&
exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-client-application", Value == “Microsoft.Exchange.RPC|Microsoft.Exchange.WebServices"]) &&
NOT exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-forwarded-client-ip", Value =~ “<public NAT addresses>"])

=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

Makes perfect sense, right? Oh…it doesn’t? Allow me to break it down:

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy"])

The ‘Type’ x-ms-proxy exists. This simply means that the claim came through an ADFS Proxy server (or other compatible proxy). Note: we are not checking the ‘value’ for this type, just that the type exists.

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path", Value == "/adfs/services/trust/2005/usernamemixed"])

The type x-ms-endpoint-absolute-path exists and has a value of usernamemixed. This is the name of the endpoint for _Active_ ADFS Claims. Summary: This is an active ADFS claim.

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-client-application", Value == "”Microsoft.Exchange.RPC|Microsoft.Exchange.WebServices"])

ClientApplication is RPC or WebServices. We can use this ‘or’ (using the ‘|’ character) syntax to check the value field.

NOT exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-forwarded-client-ip", Value =~ “\b192\.168\.4\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-5][0-9])\b|\b10\.3\.4\.5\b"])

The value for the type x-ms-forwarded-client-ip has a value that DOES NOT MATCH the regular expression “<public NAT addresses>”. That brings up two important questions:

  1. Where does that “x-ms-forwarded-client-ip” come from and what values should I expect to see there?
  2. What does the format of the regular expression look like?

According to TechNet:

“This AD FS claim represents a “best attempt” at ascertaining the IP address of the user (for example, the Outlook client) making the request. This claim can contain multiple IP addresses, including the address of every proxy that forwarded the request. This claim is populated from an HTTP header that is currently only set by Exchange Online, which populates the header when passing the authentication request to AD FS.”

(https://technet.microsoft.com/en-us/library/hh526961(v=ws.10).aspx)

So, the value is going to be the border IP address that Exchange Online (EXO) sees for the client. That would be either the border firewall doing NAT/PAT or the border Proxy server. Exchange Online add this IP to the ADFS claim request. Perfect for our Outlook scenario here: Outlook attempt to connect to EXO, EXO builds up a claims request that includes the client IP and heads out to the ADFS endpoint to submit the request.

The second question is a bit easier (or perhaps a bit harder—regular expressions can get complicated) due to the fact that the regular expression format follows the general rules for regular expressions. The Internet is full of regular expression examples to filter IP addresses. For example, let’s say that your network has one block of addresses in use in a NAT pool: 192.168.4.0-192.168.4.255. You also have one satellite office with a single public IP address: 10.3.4.5. An expression you may use could be:

“\b192\.168\.4\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-5][0-9])\b|\b10\.3\.4\.5\b”

To break that down:

\b192\.168\.4\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-5][0-9])\b applies to the 192.168.4.0-255 network.

\b192\.168\.4\. matches 192.168.4.

[1-9] matches address ending in 1-9

[1-9][0-9] matches 10-99

1[0-9][0-9] matches 100-199

2[0-5][0-9] matches 200-259 (yeah…I know a few more than needed)

The ‘|’ represent “or”

\b10\.3\.4\.5\b applies to the 10.3.4.5 address.

These can get tricky. I recommend you use a regular expression verification tool and test.

Finally, if ALL of these conditions are true:

=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

We deny the claim.

If any one of the elements of the rule evaluate to false, the entire rule is skipped. So, if the client IS coming from one of the addresses that match the regular expression, they do not match this rule.

Requirement #2: Members of a specific security group may not use ActiveSync

The previous example illustrated how to allow or block users based upon where they are. This is a simple example of how to block users based upon who they are.

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy"]) &&
exists([Type == "https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", Value =~ "Group SID value of allowed AD group"]) &&
exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-client-application, Value == “Microsoft.Exchange.ActiveSync”])

=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

The new element here from the previous example is the “groupsid” type. Yeah, you need to dive into AD and hunt down the SID of the group in question. As is hinted by the “=~” operator, you could create a regular expression that would match more than one group. You could also use the “==” operator and the “|” to do a multiple “or” match.

That one was easy—sets us up well for the next. Which gets a bit…complicated.

Requirement #3: Members of a specific group may only use OWA on the corporate network

We built a rule above that did something very similar for Outlook, couldn’t we just add on or slightly alter that rule? Nope, we can’t. OWA login uses a passive ADFS claim, so the behavior is different. With Outlook, or other active claims, all requests come from O365 and land on the external ADFS proxy. To determine if a user is internal or external, we have to examine the “x-ms-forwarded-client-ip” value. With a passive claim request, like OWA, the client’s browser will be connecting directly to the ADFS endpoint (sts.contoso.com for example). So, we can control who gets in based upon where they are asking. If they ask the external proxies (and they are in the specified group) we say “no”. If they ask the internal ADFS servers we say “yes”.

DACimage

The rule to accomplish this would look something like this:

exists([Type == https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy\])
&& exists([Type == "https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", Value =~ "S-1-5-21-299502267-1364589140-1177238915-114465"])
&& exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path", Value == "/adfs/ls/"])

=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

Line 1: Request went through a proxy

Line 2: User is a member of the specified group

Line 3: This is a passive claim to the /adfs/ls/ endpoint

Line 4: Deny

If they hit the internal ADFS servers directly, line 1 would be false and they would be allowed in.

Requirement #4: Oh…your solution to #3 doesn’t work because we want everyone to use forms-based authentication

Well…shoot. Easy enough to fix, right? Send everyone to the ADFS proxy and add in a line to the above rule that specifies which client IP addresses are allowed. Something like:

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy "])
&& exists([Type == "https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid
", Value =~ "S-1-5-21-299502267-1364589140-1177238915-114465"])
&& exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path
", Value == "/adfs/ls/"])
&& NOT exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-forwarded-client-ip", Value =~ “\b192\.168\.4\.([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-5][0-9])\b|\b10\.3\.4\.5\b"])

=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

I hope the blaring red font illustrates the point that this will not work. Why not? Scroll up a bit to the section on blocking Outlook based on IP address. Notice what fills in that x-ms-forwarded-client-ip? EXO. In this example we are dealing with _passive_ ADFS claims. EXO is not creating this claim—the user is hitting the ADFS login page directly. If you turn on this rule, everyone in the specified group will be blocked no matter where they are coming from. The x-ms-forwarded-client-ip type does not exist at all, so that line will evaluate to true. In order to make it false (and thereby stopping the deny rule from firing on someone) the element would need to exist AND the value need to match the regular expression.

If we can’t use the client’s source IP as a filter, how can we solve this problem?

The answer lies in an ADFS claim element that we have been checking all along, but not checking it fully. Let’s look at the debug log (for more on turning on debugging for ADFS see: https://technet.microsoft.com/en-us/library/adfs2-troubleshooting-configuring-computers(v=WS.10).aspx ).

Event ID 151 AD FS 2.0 Tracing:

<Claims>
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname Value CONTOSO\USER1 ValueType https://www.w3.org/2001/XMLSchema\#string Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY
ClaimType https://schemas.xmlsoap.org/ws/2005/05/identity/claims/name Value CONTOSO\USER1 ValueType https://www.w3.org/2001/XMLSchema\#string Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid Value S-1-5-21-3640651473-4051545122-2937135913-1136 ValueType https://www.w3.org/2001/XMLSchema\#string Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid Value S-1-5-15 ValueType https://www.w3.org/2001/XMLSchema\#string Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY Value S-1-5-11 Value S-1-5-2 Value S-1-5-32-545 Value S-1-1-0 Value S-1-5-21-3640651473-4051545122-2937135913-513
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/primarygroupsid Value S-1-5-21-3640651473-4051545122-2937135913-513 ValueType https://www.w3.org/2001/XMLSchema\#string Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod Value
https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/password ValueType https://www.w3.org/2001/XMLSchema\#string Issuer LOCAL AUTHORITY OriginalIssuer LOCAL AUTHORITY
ClaimType https://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationinstant Value 2012-04-19T17:32:41.459Z ValueType https://www.w3.org/2001/XMLSchema\#dateTime Issuer AD AUTHORITY OriginalIssuer AD AUTHORITY
ClaimType https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy Value adfs01p ValueType https://www.w3.org/2001/XMLSchema\#string Issuer CLIENT CONTEXT OriginalIssuer CLIENT CONTEXT
</Claims>

We have been checking for the existence of the x-ms-proxy element, but we haven’t looked into its value. The value identifies the name of the proxy server that the request passed through. What if we could tell if a user was internal or external based upon which proxy server they came through?

DACimage2

With this change, internal OWA users will land on internal ADFS Proxy servers and external OWA users will land on external ADFS Proxy servers. That will allow us to add a rule like this:

exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy", Value =~ "\badfsp[0-9][0-9]\b"])
&& exists([Type == "https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", Value =~ "S-1-5-21-299502267-1364589140-1177238915-114465"])
&& exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path", Value == "/adfs/ls/"])
=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

Line 1: User is coming through an ADFS Proxy and that proxy has a name that matches ADFSP##

Line 2: User is in the specified group

Line 3: User is hitting the passive endpoint

Line 4: Deny the claim

If a user in the specified group presents a claim to ADFS from outside the network, all elements of this rule will be true and the claim will be denied. If the same user is inside the network and is using one of the internal proxies, line 1 will be false (the proxy name will not match ADFSP##) and the claim will be allowed.

For illustration purposes, we can express the same thing in a slightly different way:

NOT exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-proxy", Value =~ "\badfspi[0-9][0-9]\b"])
&& exists([Type == "https://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid", Value =~ "S-1-5-21-299502267-1364589140-1177238915-114465"])
&& exists([Type == "https://schemas.microsoft.com/2012/01/requestcontext/claims/x-ms-endpoint-absolute-path", Value == "/adfs/ls/"])
=> issue(Type = "https://schemas.microsoft.com/authorization/claims/deny", Value = "true");

Line 1: The user is NOT coming through an ADFS Proxy that matches ADFSPI##

While you may not need to meet access control requirements this complex, I hope that these notes provide some enlightenment into the ADFS claim rule language.

Steve Halligan, PFE