AD Fun Services - Playing with claim rules and attribute store to trigger MFA when the user is connected from a different country

The following post is provided as-is with no warranty nor support of any sort. This is to illustrate how flexible the Federation Role could be if you have a little imagination and some time to spare. This is not a security feature or anything like that... As a matter of fact, if you'd like to detect suspicious logon activity, I highly encourage you to have a look at this: Use Azure Active Directory sign-in and audit reports.

Here, the idea is to trigger Multi Factor Authentication if the user is connected from a different country from the one it has set in Active Directory. Again, this is just an example, it isn't really a security solution.

So why this example? To illustrate how flexible the product could be, see how to use claim rules and MFA triggers, and see how to leverage a custom attribute store...

What's the plan?


  • the user is connected through the Web Application Proxy servers
  • the user tries to access to a specific RP
  • the user has a country defined in its Active Directory different from the one its current connection is coming from


  • trigger Multi Factor Authentication, in my case a phone call will be placed and the user has to pick up and enter a PIN

So it means that the user already has a country set in Active Directory Directory Service (the attribute co) and that you already have a MFA provider. In my example, I am using an Azure MFA server (but really any MFA provider does the trick). If you are not familiar with Azure MFA and wish to get a glimpse at it, please watch/listen to this: TechNet Radio: Delivering Results: How Microsoft is Simplifying Authentication with Azure MFA. This post does not explain how to configure Azure MFA but just leverages it.

Step 0 - Setting a country in Active Directory for Alice

Yes in IT everything starts with a 0. So Alice will be our test user.

Set-ADUser -Identity Alice -Country "CA" 

Interestingly, in Active Directory you set the country by setting the country code. So here in my example, CA means Canada.

Step 1 - Creating a new claim definition

This is optional since the claim rules are fairly easy-going and do not enforce the existence of a claim definition before using them. But for the sake of using PowerShell, let's do it. We are going to need two new claims to store the country of the user and a flag to determine whether or not the country of the connection matches the country of the user in Active Directory.

Add-AdfsClaimDescription -Name "Country of the user" -ClaimType "http://yoga.corp/Claims/CountryIP" -ShortName "CountryUser"
Add-AdfsClaimDescription -Name "Country Match Flag" -ClaimType "http://yoga.corp/Claims/CountryMatch" -ShortName "CountryUserMatch"

Step 2 - Detecting the country from where Alice is connected

For this there is nothing out of the box. So let's be creative. When we want to query for an information which does not exist in the claim pipeline nor in Active Directory, we can query attribute store. By default, only Active Directory is listed as an attribute store. In fact, if you are very finicky, you have some attribute stores which are hidden... The _OpaqueIdStore, the _PasswordExpiryStore (maybe that will be the theme of another post) but none of them are providing what we need: the country from where Alice is connected. When things are not here by default we can extend the default capabilities with some customization. Here we are going to use a Custom Attribute store to give us the country. Basically, we are going to feed this custom attribute store with an public IP address, then query a public online webservice, the webservice will return the country and we will add this returned country as a claim. Custom attribute stores are DLLs that you have to develop yourself... Because I don't want to trigger multiple support calls ;) I'll just point to some documentations if you'd like to do the same: How to create a Custom Attribute Store for Active Directory Federation Services 3.0. Here is an excerpt of my custom attribute store:

using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using System.Xml;
using Microsoft.IdentityServer.ClaimsPolicy.Engine.AttributeStore;
using System.IdentityModel;

namespace GeoIPv4AS
    public class IpOperations : IAttributeStore
        public IAsyncResult BeginExecuteQuery(string query, string[] parameters, AsyncCallback callback, object state)
            if (String.IsNullOrEmpty(query) || parameters == null || parameters.Length != 1 )
                throw new AttributeStoreQueryFormatException("Something wrong with the input");
            string inputString = parameters[0];

            if (inputString == null)
                throw new AttributeStoreQueryFormatException("Query parameter cannot be null.");

            string result = null;

            switch (query)
                case "country":

                        //Getting data from the webservice
                        //Blablablabla check that the IP is a valid IP with a RegExp
   //I am hidding the actual code and URL of the webservice I am using
   //You can find than online yourself :)
   //Returning the name of the country
                        result = countryName;
                        throw new AttributeStoreQueryFormatException("The query string is not gibberish.");
            string[][] outputValues = new string[1][];
            outputValues[0] = new string[1];
            outputValues[0][0] = result;

            TypedAsyncResult<string[][]> asyncResult = new TypedAsyncResult<string[][]>(callback, state);
            asyncResult.Complete(outputValues, true);
            return asyncResult;

        public string[][] EndExecuteQuery(IAsyncResult result)
            return TypedAsyncResult<string[][]>.End(result);

        public void Initialize(Dictionary<string, string> config)
            // No initialization is required for this store.

Once compiled, I have my GeoIPAS.dll that I copy to the C:\Windows\ADFS folder of all my ADFS servers. Then I create my custom attribute store in the console:

Once the store has been created, I restart my ADFS server and confirm the event 251 in the ADFS admin logs:

Now I can call the store with an IP in input and it will return me the country. The input in our case will be the claim which is set with the external IP address of the client when it is coming through a Web Application Proxy. So the following claim rule gives you an example of how to leverage the custom attribute store:

c:[ Type == "" ]
=> Issue( Store = "GeoIPAS", Types = ("http://yoga.corp/Claims/CountryIP"), Query = "country", Param = c.Value );

This rule will issue a claim http://yoga.corp/Claims/CountryIP with the country for the IP of

Step 3 - Creating the MFA trigger

So to trigger MFA we will need to do 3 things (well, there are multiple ways to do it).

  1. We query the attribute store if the user is connected from the WAP (so is false) and has an IP address in the claim ( has a value). If so, then we call the custom attribute store, which calls the webservice, which returns the name of the country and issues it in a claim type call http://yoga.corp/Claims/CountryIP (well technically, we could use the statement add since none of the things issued at that level of the pipeline actually end up in the token).c1:[ Type == "", Value == "false" ] && c2:[ Type == "" ]
     => Issue( Store = "GeoIPAS", Types = ("http://yoga.corp/Claims/CountryIP"), Query = "country", Param = c2.Value );
  2. We query AD and check if the user's country in AD matched the country returned by the webservice (via the custom attribute store). If so, we issue a new claim http://yoga.corp/Claims/CountryMatch (its value actually doesn't matter since we are just using it as a flag):c1:[ Type == "", Issuer == "AD AUTHORITY" ] && c2:[ Type == "http://yoga.corp/Claims/CountryIP" ]
     => Issue( Store = "Active Directory", Types = ("http://yoga.corp/Claims/CountryMatch"), Query = "(co={1});co;{0}", Param = c1.Value , Param = c2.Value );
  3. Finally, if the flag http://yoga.corp/Claims/CountryMatch does not exist in the pipeline, we issue the claim with the value which will trigger the MFA (if the user is connected externally), else nothing happens and the claim engine moves on:NOT EXISTS( [ Type == "http://yoga.corp/Claims/CountryMatch" ] ) && C:[ Type == "", Value == "false" ]
    => Issue( Type = "", Value = "");

Now that we have the logic, we will add the trigger for a specific relying party trust. In my case, the RP is called MFACountryExample:

$_countryMFA = @"
c1:[ Type == "", Value == "false" ] && c2:[ Type == "" ]
=> Issue( Store = "GeoIPAS", Types = ("http://yoga.corp/Claims/CountryIP"), Query = "country", Param = c2.Value );
c1:[ Type == "", Issuer == "AD AUTHORITY" ] && c2:[ Type == "http://yoga.corp/Claims/CountryIP" ]
=> Issue( Store = "Active Directory", Types = ("http://yoga.corp/Claims/CountryMatch"), Query = "(co={1});co;{0}", Param = c1.Value , Param = c2.Value );
NOT EXISTS( [ Type == "http://yoga.corp/Claims/CountryMatch" ] ) && c:[ Type == "", Value == "false" ]
=> Issue( Type = "", Value = "");

Get-AdfsRelyingPartyTrust -name "MFACountryExample" | Set-AdfsRelyingPartyTrust -AdditionalAuthenticationRules $_countryMFA 

And here you go... Note that as soon as you are using custom rules for MFA triggers, you won't see the options in the GUI anymore:

Step 4 - Playing with it

Of course in a real life example you have way more things to consider... First the error management of your custom attribute store should be solid, including parsing the input because after all, this is an HTTP header, the client could try to modify it and put something that would make your code crash. Then you create a dependency of that web service with an outgoing network connection to the Internet... For that you could use your own webservice or come up with some multi tier thing... Anyhow, instead of going fancy on your ADFS server, just use the one we created for you: Use Azure Active Directory sign-in and audit reports. If you have never seen those reports, here is an example of suspicious activity detected by Azure AD:

Cool eh? So maybe it's better for you to go that way instead of the geek way :)