Extending Your Azure Active Directory – Part 2

In the previous post we built a web app that would let us add a custom attribute to our Active Directory tenant in Azure AD, and manipulate the value of this attribute on a per user basis. If you haven’t read the previous article I strongly suggest going back and doing so, or this might not make much sense Smilefjes

In this part I’m going to build another app that will use the information the first app provisioned into Azure AD. The first app let me register YubiKeys, and this app will let me authenticate based on these keys. The code is fully functional without the physical keys, so you will be able to follow along even if you don’t have one in your hand.

This app could have been implemented as a Windows Phone app, an Android app, or what have you really, but to make it available for all scenarios without too much fuss I’m building it as a web app. (Which is an entirely plausible use case by it’s own merits.)

So, once again start your Visual Studio and create a new project:
Dir_Ext_App_01

I’ll be building a multi-tenant app this time as well, but for now it’s sufficient to request "Read" access to Azure AD as we’re not writing anything back from this app.
Dir_Ext_App_02

After the wizard has finished we’ll start off by creating a file for all our models; AzureADModels.cs:
Dir_Ext_App_03

The contents of the file are the same as in the previous article:

Make sure you change the namespace to match this app πŸ™‚
(We don’t use all these classes currently, so there are some extra lines of code there you technically don’t need, but we’ll just leave it.)

There isn’t currently an easy way "App B" can list the extension properties registered by "App A", so for the time being we rely on a "hack" to save us a bunch of coding. (I’m not saying there are no ways, I’m saying it requires extra effort that we don’t want to do right now.) What we need to do is to add another app setting (ida:ExtensionName) to our web.config file with the name of the extension property we’re looking for:
Dir_Ext_App_04

You can retrieve this by running the first web app and step through the debugger. (The variable is called ExtensionName.)

"App B" will have access to the extension properties created by "App A" as the permission is based on having access to the Azure AD tenant itself, not the individual attributes within the directory.

The content of the page will be fairly barebones, only showing that you’re able to authenticate properly. Open Views=>Home=>Index.cshtml, and remove it’s contents. Replace the with the following code:

As you can see we just want to print out the user’s name (based on the built-in identity framework). If you’re not authenticated you’re not going to be able to see the view at all so there are no non-authenticated texts for fallback.

Now open up the HomeController.cs file – you can remove the About and Contact methods if you feel they’re cluttering the view. The UserProfile method isn’t necessary either, but might come in handy if you’re having problems verifying your Azure AD connectivity. Mine ends up like this:

There are potentially a lot of different ways we can implement authentication schemes for logging in with the YubiKey. A fairly clean approach in my book is to add custom authorization filters. This way we don’t have to mess around too much with things we shouldn’t, and it plugs very nicely into the MVC framework. And just like using the [Authorize] attribute we can choose to apply the filter to individual methods/views, controllers or the entire app.

Add a using statement for DirectoryExtensionApp.Filters, and a YubiKeyFilter attribute to the Index method:
Dir_Ext_App_05

You’ll notice that I’ve attached a tenant id to the attribute. This is the id for our Azure AD tenant. This is used in the filter to query the correct Azure AD tenant for YubiKeyIds so it simplifies things to place it in the controller. It doesn’t have to be in the controller, and could have been placed in the filter itself, but I didn’t want to do that for now. Effectively this turns our multi-tenant app into a single-tenant app, but if you felt compelled to it you could add code to handle a list of tenants that are ok. I’d probably include the tenant as part of the url/query string or a custom header to handle this. (If you code the entire url into the YubiKey Neo for triggering login with NFC it will not work to use custom headers though.)

To implement the filter create a new folder in your project called "Filters", and add a new file named YubiKeyFilter.cs to it:
Dir_Ext_App_06

We need to do a couple of things in this filter:
– Extend AuthorizeAttribute
– Provide a constructor
– Override AuthorizeCore & HandleUnauthorized
– Create a helper method to verify the YubiKeyId

The first three steps are fairly simple:

        public YubiKeyFilterAttribute(string tenantId)
        {
            this.tenantId = tenantId;
        }
 
        protected override bool AuthorizeCore(HttpContextBase httpContext)
        {
            var clientYubiKeyId = httpContext.Request.QueryString["keyId"];
            var isAuthorized = IsAuthorizedYubiKeyId(clientYubiKeyId, tenantId);
            
            return isAuthorized;
        }
 
        //The default behavior is a 401 Unauthorized when failing the auth process.
        //This will trigger a new auth attempt, which is not what we want.
        //So we override and return 403 instead.
        protected override void HandleUnauthorizedRequest(System.Web.Mvc.AuthorizationContext filterContext)
        {
            filterContext.Result = new HttpStatusCodeResult(HttpStatusCode.Forbidden);
        }

You’ll notice that I’m pulling the id to check from the query string, and passing this along with the tenant id into a helper method.

HandleUnauthorizedRequest will by default return HTTP 401 triggering a new authentication attempt, and in this case sending us off to login to Azure AD directly. Since this isn’t want we want we change this behavior to return HTTP 403 instead. If you want to be more fancy you can return a redirect to a user friendly error message letting the user know why it failed by doing something like this:
Dir_Ext_App_07

Note that this code will not work by just doing this in the filter; you need to create the ErrorController.cs controller and it’s corresponding view as well. I’ll leave that as an exercise for the reader. (Oh, and you’ll want to override the 401 code in the ErrorController too.)

Next step is to build out the main helper method; IsAuthorizedYubiKeyId:

        private static bool IsAuthorizedYubiKeyId(string YubiKeyId, string tenantId)
        {
            //If user has already been authenticated we will OK that without further processing.
            if (HttpContext.Current.User.Identity.IsAuthenticated)
            {
                return true;
            }
 
            if (!string.IsNullOrEmpty(YubiKeyId))
            {                
                // Get a token for calling the Windows Azure Active Directory Graph
                AuthenticationContext authContext = new AuthenticationContext(String.Format(CultureInfo.InvariantCulture, LoginUrl, tenantId));
                ClientCredential credential = new ClientCredential(AppPrincipalId, AppKey);
                AuthenticationResult assertionCredential = authContext.AcquireToken(GraphUrl, credential);
                string authHeader = assertionCredential.CreateAuthorizationHeader();
 
                string requestUrl = String.Format(
                    CultureInfo.InvariantCulture,
                    GraphUsersUrl,
                    HttpUtility.UrlEncode(tenantId));
                
                //Only interested in the users with a matching YubiKeyID
                requestUrl += "&$filter=" + ExtensionName + " eq " + "'" + YubiKeyId + "'";
 
                HttpClient client = new HttpClient();
                HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
                request.Headers.TryAddWithoutValidation("Authorization", authHeader);
                HttpResponseMessage response = client.SendAsync(request).Result;
                
                string responseString = response.Content.ReadAsStringAsync().Result;
                                
                UserContext ctx = JsonConvert.DeserializeObject<UserContext>(responseString);
                //If we're not getting any results your id was not valid.
                if (ctx.value.Count == 0)
                {
                    return false;
                }
 
                UserDetails user = ctx.value[0];                                
                user.YubiKeyId = YubiKeyId;
 
                //Now that we know you're OK we'll create a new identity for you,
                //and attach it to the current context.
                ClaimsIdentity ci = new ClaimsIdentity(
                    "Federated", 
                    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", 
                    "http://schemas.microsoft.com/ws/2008/06/identity/claims/role");
                Claim name = new Claim(
                    "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", 
                    user.userPrincipalName);
                ci.AddClaim(name);                
 
                ClaimsPrincipal cp = new ClaimsPrincipal(ci);
                HttpContext.Current.User = cp;
                
                return true;
            }
            return false;
        }

Let me explain the flow of this code.

We issue a GET to Azure AD with a filter. This means we should get exactly one user object in the response matching the specific YubiKeyId we’re looking for. (Yes, this means the id will have to be unique per user. If you play around debugging stuff and setting the same id for more than one user it will break.)

Then we populate the user object both based on what Azure AD gave us, and the YubiKeyId supplied in the query string. Why not use the id from the extension property as returned by Azure AD? Well, remember that I said we don’t know the exact name of the property in advance? This means it can’t be directly deserialized by JsonConvert, and instead of messing around parsing the raw JSON I just used the value I already have in memory πŸ™‚

The last part is creating a new claims-based identity and attach it to the current HttpContext. I only added the name claim, but it would possibly make sense to add more claims to the identity once you’re at it.

The file in it’s entirety looks like this:

If you’re able to build it now it should be ready for testing πŸ™‚

When the app loads up you will be greeted with a nice red error message telling you that you’re not welcome:
Dir_Ext_App_08

This is expected as we haven’t enabled anonymous access to the Index view so we need to attach our id as part of the request. In the previous article I registered a YubiKey with the id "1234":
DirExt_08

We need to provide this value as part of the query string:
Dir_Ext_App_09

After correcting this it should look similar to this when you load the page:
Dir_Ext_App_10

If you don’t believe it’s working try to change the id in the query string πŸ™‚

To further mess around I suggest having both web apps open at the same time, and verify that changing the properties in the first web app propagates to the second web app fairly instantaneously.

This turned out to be an exercise of some length, but we’ve covered a lot of functionality. We covered both how to interact with Azure AD and "extend the schema", as well as how we can customize the authentication flow in a web app.

You can probably think of plenty other scenarios where the functionality introduced here can be applied, and you can probably come up with more elegant solutions than me as well. I hope that there was enough here to pique your interest and get hacking with Azure AD and it’s APIs πŸ™‚

In case you don’t want to build everything, or you messed up something along the way I commited a solution with both apps to GitHub:
https://github.com/ahelland/DirectoryExtensions
(Some assembly required to fill in correct ids/passwords/etc. to match your AD tenant.)

Leave a Reply

Your email address will not be published. Required fields are marked *

*