Using Azure AD Directory Extensions with Calendar Publishing

I ran through a setup three weeks ago where I used the “Directory Extensions” preview feature in Azure Active Directory to show how I could store an extra id on the user object and use this attribute in a different web app:
http://mobilitydojo.net/2014/04/08/extending-your-azure-active-directory-part-1/

Not feeling entirely done with creating samples I’ll be building another web app showing another scenario where directory extensions might be a useful approach. We’ll extract some data from Office 365 (Exchange Online more specifically), and insert into Azure AD and re-use it.

Exchange Online has this neat feature where you can publish your calendar externally so anyone can check it without being a member of your Active Directory. Actually, it’s not just Office 365 users who get this – Exchange 2013 on-prem can do so as well, but this sample will only explore the clouded version. (You can probably tweak it to work with a local Exchange Server if you like; the differences are probably fairly minor.) I’m not saying there aren’t drawbacks to using this feature, you certainly should not expose all details in your calendar to the general public, but it can be useful in a couple of scenarios and you don’t have to share all the details either.

For details on how to set this up take a look at the Exchange Team’s walkthrough:
How to publish Anonymous Calendar Sharing URL in Exchange Online or Exchange 2013
http://blogs.technet.com/b/exchange/archive/2014/04/15/how-to-publish-anonymous-calendar-sharing-url-in-exchange-online-or-exchange-2013.aspx

The url that you will need to make available isn’t exactly something that rolls off the tongue, or easy to guess based on the username, so you need to mail it to people or post as a link on a web page. If you go through the process manually as a user that’s simple enough, but doing it in code or a different UI? Now, I could of course be wrong, but I’m not finding this attribute exposed in Active Directory. Exchange has a lot of information related to mailboxes (obviously), and many of these attributes are replicated to AD, but not all of them. So, the idea here is that I want to be able to enable/disable publishing of calendars, and in the case of publishing the calendar I also want to get the url. This url will be stored as an extension on the user object in Azure AD (since we’re going for the clouded options in general here).

When publishing the calendar in OWA as a user you can choose which access level to expose. Here I enable it as an administrator, and then it defaults to only showing availability. (I can’t set it to a different level as an admin.)

I usually start with the coding bits, but this time I felt it might make more sense to start with what it will look like before taking you through the steps to get there.

I pieced together an administrator view for enabling and disabling publishing of calendars:
Shared_Calendar_01

The view exposed to the end-user shows off the links as QR codes in a table. (Idea being that you have the overview on a desktop/table and want to share with a smartphone of some sort.) This was as much to play with generating QR codes as anything else, so if you prefer regular hyperlinks that’s just a matter of changing a line of Razor markup.
Shared_Calendar_02

So, how does this work behind the scenes? There’s really not that many parts involved:
– Remote PowerShell to Office 365 to run the cmdlets necessary to publish the calendars, and retrieve the urls.
– Checking for, and if not present registering, a directory extension.
– Retrieving value of said extension and passing it into a custom HtmlHelper for generating QR codes.

You can start by creating an MVC Web App with Read and Write permissions to your directory (multi-tenant mode). For detailed steps on this part refer to my previous article.

Now that the auto-generated code is ready let’s add some code of our own.
First let’s edit web.config:
Add two appSettings; o365Username & o365password.

  <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
    <add key="ClientValidationEnabled" value="true" />
    <add key="UnobtrusiveJavaScriptEnabled" value="true" />
        <add key="ida:FederationMetadataLocation" value="https://login.windows.net/contoso.onmicrosoft.com/FederationMetadata/2007-06/FederationMetadata.xml" />
    <add key="ida:Realm" value="https://contoso.onmicrosoft.com/DirectoryExtensions" />
    <add key="ida:AudienceUri" value="https://contoso.onmicrosoft.com/DirectoryExtensions" />
    <add key="ida:ClientID" value="cryptic_value" />
    <add key="ida:Password" value="cryptic_value" />
    <add key="o365Username" value="admin@contoso.onmicrosoft.com"/>
    <add key="o365Password" value="password"/>
  </appSettings>

The values for these settings is the credentials of an account that has admin access to Exchange Online and will be used for enabling and disabling the sharing. You might consider securing these credentials better than plain-text in this file, but this is the easy fix for now 🙂

The reason I’m specifying these explicitly is that I can’t use the token I get by signing in to Azure AD as PowerShell wants “plain credentials” to login. (I am looking into other approaches here, but for now I do it this way.)

Next add a file called AzureADModels.cs to the Models folder:
Shared_Calendar_03

using Newtonsoft.Json;
using System.Collections.Generic;
using System.ComponentModel;


namespace CalendarSharing.Models
{
    public class UserContext
    {
        [JsonProperty("odata.metadata")]
        public string userContext;


        [JsonProperty("value")]
        public List<UserDetails> value;
    }


    public class UserDetails
    {
        [JsonProperty("objectId")]
        public string objectId { get; set; }

        public List<assignedPlan> assignedPlans { get; set; }

        [DisplayName("Display Name")]
        public string displayName { get; set; }
        [DisplayName("Given Name")]
        public string givenName { get; set; }
        [DisplayName("Surname")]
        public string surname { get; set; }
        [DisplayName("Alias")]
        public string mailNickname { get; set; }
        [DisplayName("Job Title")]
        public string jobTitle { get; set; }
        [DisplayName("Department")]
        public string department { get; set; }
        [DisplayName("Mobile")]
        public string mobile { get; set; }
        [DisplayName("City")]
        public string city { get; set; }
        [DisplayName("Street Address")]
        public string streetAddress { get; set; }
        [DisplayName("Country")]
        public string country { get; set; }
        [DisplayName("Postal Code")]
        public string postalCode { get; set; }
        [DisplayName("Phone Number")]
        public string telephoneNumber { get; set; }
        [DisplayName("Email Address")]
        public string mail { get; set; }
        [DisplayName("UPN")]
        public string userPrincipalName { get; set; }
        [DisplayName("Last DirSync")]
        public string lastDirSyncTime { get; set; }
        [DisplayName("Public Calendar")]        
        public bool PublishedCal { get; set; }
        [DisplayName("Published Calendar Url")]
        public string PublishedCalendarUrl { get; set; }
        [DisplayName("Published ICal Url")]
        public string PublishedICalUrl { get; set; }
    }

    public class assignedPlan
    {
        public string assignedTimestamp { get; set; }
        public string capabilityStatus { get; set; }
        public string service { get; set; }
        public string servicePlanId { get; set; }
    }

    public class AppContext
    {
        [JsonProperty("odata.metadata")]
        public string metadata { get; set; }
        public List<AppDetails> value { get; set; }
    }


    public class AppDetails
    {
        public string objectId { get; set; }
        public string appId { get; set; }
    }


    public class ExtensionPropertiesContext
    {
        [JsonProperty("odata.metadata")]
        public string metadata { get; set; }
        public List<ExtensionProperty> value { get; set; }
    }


    public class ExtensionProperty
    {
        public string objectId { get; set; }
        public string objectType { get; set; }
        public string name { get; set; }
        public string dataType { get; set; }
        [JsonProperty("odata.metadata")]
        public string odataMetadata { get; set; }
        [JsonProperty("odata.type")]
        public string odataType { get; set; }
        public List<string> targetObjects { get; set; }
    }
}

This is just a representation of the user object as exposed by Azure AD.

Next add a file called DirectoryExtensions.cs to the Utils folder:
Shared_Calendar_04

using CalendarSharing.Models;
using Newtonsoft.Json;
using System;
using System.Configuration;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web;

namespace CalendarSharing.Utils
{
    public static class DirectoryExtensions
    {
        private const string TenantIdClaimType = "http://schemas.microsoft.com/identity/claims/tenantid";
        private const string LoginUrl = "https://login.windows.net/{0}";

        private const string ApiVersion = "1.21-preview";
        private const string GraphUrl = "https://graph.windows.net";
        private const string GraphUserUrl = "https://graph.windows.net/{0}/users/{1}?api-version=" + ApiVersion;
        private const string GraphUsersUrl = "https://graph.windows.net/{0}/users?api-version=" + ApiVersion;
        private const string GraphUsersByTenantUrl = "https://graph.windows.net/{0}/users?api-version=" + ApiVersion;
        private const string GraphApps = "https://graph.windows.net/{0}/applications?api-version=" + ApiVersion;
        private const string GraphAppUrl = "https://graph.windows.net/{0}/applications/{1}/extensionProperties?api-version=" + ApiVersion;
        private const string GraphExtensionValueUrl = "https://graph.windows.net/{0}/users/{1}?api-version=" + ApiVersion;        
        private const string GraphExtensionUrl = "https://graph.windows.net/{0}/applications/{1}/extensionProperties?api-version=" + ApiVersion;        

        private static readonly string AppPrincipalId = ConfigurationManager.AppSettings["ida:ClientID"];
        private static readonly string AppKey = ConfigurationManager.AppSettings["ida:Password"];

        public static async Task<string> getAppObjectId(string tenantId, string authHeader)
        {
            string appObjectId = string.Empty;

            //Get the objectId for this particular app
            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphApps,
                HttpUtility.UrlEncode(tenantId));

            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);
            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();

            var apps = JsonConvert.DeserializeObject<AppContext>(responseString);

            //Iterate through the list to find the correct application,
            //and retrieve it's object id
            for (int i = 0; i < apps.value.Count; i++)
            {
                if (apps.value[i].appId == AppPrincipalId)
                {
                    appObjectId = apps.value[i].objectId;
                }
            }

            return appObjectId;
        }

        public static async Task<string> checkExtensionRegistered(string tenantId, string authHeader, string appObjectId, string extensionName)
        {
            //Get extensions for this app
            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphAppUrl,
                HttpUtility.UrlEncode(tenantId),
                HttpUtility.UrlEncode(appObjectId));

            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);
            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();

            var extensionproperties = JsonConvert.DeserializeObject<ExtensionPropertiesContext>(responseString);

            if (extensionproperties.value.Count == 0)
                return "false";
            else
            {                
                var extensions = extensionproperties.value;
                for (int i = 0; i < extensionproperties.value.Count; i++)
                {
                    if (extensionproperties.value[i].name.Contains(extensionName))
                        return extensionproperties.value[i].name;
                }
            }

            return "false";
        }

        public static async Task<string> registerExtension(string tenantId, string authHeader, string appObjectId, string extensionName)
        {
            //Get the objectId for this particular app
            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphExtensionUrl,
                HttpUtility.UrlEncode(tenantId),
                HttpUtility.UrlEncode(appObjectId));

            HttpClient client = new HttpClient();
            client.DefaultRequestHeaders.ExpectContinue = false;
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);

            request.Content = new StringContent("{\"name\": \"" + extensionName + "\",\"dataType\": \"String\",\"targetObjects\": [\"User\"]}", System.Text.Encoding.UTF8, "application/json");

            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();
            if (response.StatusCode == HttpStatusCode.Created)
            {
                var extension = JsonConvert.DeserializeObject<ExtensionProperty>(responseString);
                return extension.name;
            }
            else
                return string.Empty;
        }

        public static async Task<bool> setExtensionValue(string tenantId, string authHeader, string upn, string extensionName, string extensionValue)
        {
            //Get the objectId for this particular app
            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphExtensionValueUrl,
                HttpUtility.UrlEncode(tenantId),
                HttpUtility.UrlEncode(upn));

            HttpClient client = new HttpClient();
            client.DefaultRequestHeaders.ExpectContinue = false;
            //PATCH isn't a default method
            HttpRequestMessage request = new HttpRequestMessage(new HttpMethod("PATCH"), requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);

            string extensionProperty = "{\"" + extensionName + "\":\"" + extensionValue + "\"}";

            request.Content = new StringContent(extensionProperty, System.Text.Encoding.UTF8, "application/json");

            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();
            if (response.StatusCode == HttpStatusCode.NoContent)
            {
                return true;
            }
            else
                return false;
        }

        public static async Task<string> getExtensionValue(string tenantId, string authHeader, string upn, string extensionName)
        {            
            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphUserUrl,
                HttpUtility.UrlEncode(tenantId),
                HttpUtility.UrlEncode(upn));
            
            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);
            HttpResponseMessage response = client.SendAsync(request).Result;

            string responseString = await response.Content.ReadAsStringAsync();
            
            Newtonsoft.Json.Linq.JObject jUser = Newtonsoft.Json.Linq.JObject.Parse(responseString);
            string calendarUrl = (string)jUser[extensionName];
            return calendarUrl;
        }

    }
}

This class handles the interaction with the Graph API for registering and setting the extension value.

Before moving on we need to add a package through NuGet; System.Management.Automation.dll. This adds a file you may already have on your system, but it’s easier to add this way. This dll contains the namespace for using PowerShell in C#.

Next add “Office365Remoting.cs” to the same Utils folder:

using CalendarSharing.Models;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Configuration;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Security;

namespace CalendarSharing.Utils
{
    public static class Office365Remoting
    {
        private static readonly string username = ConfigurationManager.AppSettings["o365Username"];
        private static readonly string password = ConfigurationManager.AppSettings["o365Password"];

        public static WSManConnectionInfo getOffice365Connection()
        {            
            SecureString securePassword = new SecureString();
            foreach (char c in password)
                securePassword.AppendChar(c);
            securePassword.MakeReadOnly();

            PSCredential o365credentials = new PSCredential(username, securePassword);

            WSManConnectionInfo connectionInfo = new WSManConnectionInfo(
                new Uri("https://ps.outlook.com/Powershell-LiveID?PSVersion=3.0"), 
                "http://schemas.microsoft.com/powershell/Microsoft.Exchange", 
                o365credentials);
            connectionInfo.AuthenticationMechanism = AuthenticationMechanism.Basic;
            connectionInfo.SkipCACheck = true;
            connectionInfo.SkipCNCheck = true;
            connectionInfo.MaximumConnectionRedirectionCount = 4;

            return connectionInfo;
        }

        public static PSCommand ParseCommand(string commandLine)
        {
            PSCommand command = new PSCommand();
            int i = commandLine.IndexOf(" ");
            if (i < 0)
            {
                command.AddCommand(commandLine);
                return command;
            }

            // Add the command
            command.AddCommand(commandLine.Substring(0, i));

            // Now parse and add parameters
            try
            {
                i = commandLine.IndexOf("-", i);
                while ((i > 0) && (i < commandLine.Length))
                {
                    int j = commandLine.IndexOf("-", i + 1);
                    if (j < 0) j = commandLine.Length;
                    int p = commandLine.IndexOf(" ", i + 1);
                    string sParamName = commandLine.Substring(i + 1, p - i - 1);
                    string sParamValue = commandLine.Substring(p + 1, j - p - 1);
                    if (sParamValue.StartsWith("\"") && sParamValue.EndsWith("\""))
                        sParamValue = sParamValue.Substring(1, sParamValue.Length - 2);
                    command.AddParameter(sParamName, sParamValue);
                    i = j;
                }
            }
            catch (Exception)
            {
                
            }
            return command;
        }

        public static List<UserDetails> getCalendarUrls(List<UserDetails> users, WSManConnectionInfo connection)
        {
            PowerShell o365ps = null;            

            try
            {                
                o365ps = PowerShell.Create();                
                o365ps.Runspace = RunspaceFactory.CreateRunspace(connection);
                o365ps.Runspace.Open();

                List<string> commands = new List<string>();

                foreach (var user in users)
                {
                    commands.Add("Get-MailboxCalendarFolder -Identity " + user.mailNickname + ":\\Calendar");

                    Collection<PSObject> results = null;
                    o365ps.Commands = ParseCommand(commands[0]);
                    results = o365ps.Invoke<PSObject>();

                    string calendarUrl = string.Empty;
                    string identity = string.Empty;
                    foreach (PSObject obj in results)
                    {
                        var rawIdentity = obj.Properties["Identity"].Value.ToString().Split(':');
                        identity = rawIdentity[0];
                        if (identity.ToLowerInvariant() == user.mailNickname.ToLowerInvariant())
                        {
                            calendarUrl = obj.Properties["PublishedCalendarUrl"].Value.ToString();
                            user.PublishedCalendarUrl = calendarUrl;
                        }
                    }
                    commands.Clear();
                }

            }
            catch (Exception)
            {
                //something went wrong
            }
            finally
            {                
                o365ps.Runspace.Close();
                o365ps.Runspace.Dispose();
            }

            return users;
        }

        public static List<UserDetails> enablePublishedCalendars(List<UserDetails> users, WSManConnectionInfo connection)
        {
            PowerShell o365ps = null;

            try
            {
                o365ps = PowerShell.Create();
                o365ps.Runspace = RunspaceFactory.CreateRunspace(connection);
                o365ps.Runspace.Open();

                List<string> commands = new List<string>();

                foreach (var user in users)
                {
                    commands.Add("Set-MailboxCalendarFolder -Identity " + user.mailNickname + ":\\Calendar" + "-PublishEnabled:$true");

                    Collection<PSObject> results = null;
                    o365ps.Commands = ParseCommand(commands[0]);

                    //For a successfully enabled calendar there is no return value
                    results = o365ps.Invoke<PSObject>();

                    commands.Clear();
                }

            }
            catch (Exception)
            {
                //something went wrong
            }
            finally
            {
                o365ps.Runspace.Close();
                o365ps.Runspace.Dispose();
            }

            return users;
        }

        public static List<UserDetails> disablePublishedCalendars(List<UserDetails> users, WSManConnectionInfo connection)
        {
            PowerShell o365ps = null;

            try
            {
                o365ps = PowerShell.Create();
                o365ps.Runspace = RunspaceFactory.CreateRunspace(connection);
                o365ps.Runspace.Open();

                List<string> commands = new List<string>();

                foreach (var user in users)
                {
                    commands.Add("Set-MailboxCalendarFolder -Identity " + user.mailNickname + ":\\Calendar" + "-PublishEnabled:$false");

                    Collection<PSObject> results = null;
                    o365ps.Commands = ParseCommand(commands[0]);

                    //For a successfully disabled calendar there is no return value
                    results = o365ps.Invoke<PSObject>();

                    commands.Clear();
                }

            }
            catch (Exception)
            {
                //something went wrong
            }
            finally
            {
                o365ps.Runspace.Close();
                o365ps.Runspace.Dispose();
            }

            return users;
        }
    }
}

This class takes care of remoting to Office 365 PowerShell, and executing the necessary commands for setting the publish state to enable/disable, as well as retrieving the value of the url for any published calendar. It’s not very generic in the sense that the actual cmdlets executed are pieced together in a manual sense, but that’s just the way things are.

You might notice when hitting the “Enable/Disable” button that things seem to be running at a snail’s pace, and you find yourself thinking it’s stuck. No, that’s just the remote PowerShelling taking forever to execute. There is an “unoptimization” on my part in that I open and close the remote session a couple of times. By adding logic to acquire the session once, and executing cmdlets in sequence you’ll save yourself some time, but to make the code easier to read I opted for the current approach. Thing is, you have to be really sure that you close and dispose of the session properly of you’ll find yourself having to wait for the open sessions to time out server side before you’re allowed to open new ones. (The limit isn’t one per user; it’s something like three or four, but that still makes for a painful debugging if you have to wait a couple of hours to get an available slot.) So, even cleaning up my code you’ll think there’s something wrong.

Next we’ll edit the HomeController.cs file to get the desired output in the UI. We edit the existing Index method, as well as add a new method “PublishCalendars”.

using System;
using System.Collections.Generic;
using System.Configuration;
using System.Globalization;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Newtonsoft.Json;
using CalendarSharing.Models;
using System.Management.Automation.Runspaces;
using CalendarSharing.Utils;

namespace CalendarSharing.Controllers
{
    public class HomeController : Controller
    {
        private const string TenantIdClaimType = "http://schemas.microsoft.com/identity/claims/tenantid";
        private const string LoginUrl = "https://login.windows.net/{0}";

        private const string ApiVersion = "1.21-preview";
        private const string GraphUrl = "https://graph.windows.net";
        private const string GraphUserUrl = "https://graph.windows.net/{0}/users/{1}?api-version=2013-04-05";
        private const string GraphUsersByTenantUrl = "https://graph.windows.net/{0}/users?api-version=" + ApiVersion;
        private const string GraphApps = "https://graph.windows.net/{0}/applications?api-version=" + ApiVersion;
        private const string GraphAppUrl = "https://graph.windows.net/{0}/applications/{1}/extensionProperties?api-version=" + ApiVersion;
        
        private static readonly string AppPrincipalId = ConfigurationManager.AppSettings["ida:ClientID"];
        private static readonly string AppKey = ConfigurationManager.AppSettings["ida:Password"];

        [Authorize]
        public async Task<ActionResult> Index()
        {
            string tenantId = ClaimsPrincipal.Current.FindFirst(TenantIdClaimType).Value;

            // 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 appObjectId = await DirectoryExtensions.getAppObjectId(tenantId, authHeader);

            string extensionName = string.Empty;

            //Check if PublishedCalendar extension is registered by trying to get the id
            extensionName = await DirectoryExtensions.checkExtensionRegistered(tenantId, authHeader, appObjectId, "PublishedCalendarUrl");

            if (extensionName == "false")
            {
                ViewBag.UserCount = 0;
                return View();
            }

            string requestUrl = String.Format(
                CultureInfo.InvariantCulture,
                GraphUsersByTenantUrl,
                HttpUtility.UrlEncode(tenantId));

            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);
            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();
            UserContext userCtx = JsonConvert.DeserializeObject<UserContext>(responseString);

            List<UserDetails> users = new List<UserDetails>(userCtx.value);
            List<UserDetails> usersWithPublishedCalendar = new List<UserDetails>();
            foreach (var user in users)
            {
                //If a user doesn't have an Office 365 plan we can't use their calendar so we exclude them from the list
                if (user.assignedPlans.Count > 0)
                {
                    //If a user isn't assigned to the "Exchange" plan they don't have a calendar either
                    var exchangePlan = user.assignedPlans.Exists(e => e.service.Contains("exchange"));
                    if (exchangePlan)
                    {
                        user.PublishedCalendarUrl = await DirectoryExtensions.getExtensionValue(tenantId, authHeader, user.userPrincipalName, extensionName);
                        //If a user hasn't shared his calendar, don't add to list
                        if (user.PublishedCalendarUrl != null)
                            usersWithPublishedCalendar.Add(user);
                    }                    
                }                                
            }            

            ViewBag.UserCount = usersWithPublishedCalendar.Count;

            return View(usersWithPublishedCalendar);
        }        

        [Authorize]
        public async Task<ActionResult> PublishCalendars()
        {
            string tenantId = ClaimsPrincipal.Current.FindFirst(TenantIdClaimType).Value;

            // 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,
                GraphUsersByTenantUrl,
                HttpUtility.UrlEncode(tenantId));                

            HttpClient client = new HttpClient();
            HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
            request.Headers.TryAddWithoutValidation("Authorization", authHeader);
            HttpResponseMessage response = await client.SendAsync(request);
            string responseString = await response.Content.ReadAsStringAsync();
            UserContext userCtx = JsonConvert.DeserializeObject<UserContext>(responseString);            

            List<UserDetails> usersInTenant = new List<UserDetails>(userCtx.value);
            List<UserDetails> usersWithCalendar = new List<UserDetails>();

            //Since the extension name isn't known in advance it's not included in the default serialization,
            //so we extract it manually after looking up the name.
            string appObjectId = await DirectoryExtensions.getAppObjectId(tenantId, authHeader);
            string extensionName = string.Empty;
            extensionName = await DirectoryExtensions.checkExtensionRegistered(tenantId, authHeader, appObjectId, "PublishedCalendarUrl");

            foreach (var user in usersInTenant)
            {
                //If a user doesn't have an Office 365 plan we can't use their calendar so we exclude them from the list
                if (user.assignedPlans.Count > 0)
                {
                    //If a user isn't assigned to the "Exchange" plan they don't have a calendar either
                    var exchangePlan = user.assignedPlans.Exists(e => e.service.Contains("exchange"));
                    if (exchangePlan)
                    {
                        user.PublishedCalendarUrl = await DirectoryExtensions.getExtensionValue(tenantId, authHeader, user.userPrincipalName, extensionName);                        
                        usersWithCalendar.Add(user);
                    }
                }
            }
            return View(usersWithCalendar);            
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> PublishCalendars(List<UserDetails> users)
        {
            //We create separate lists for users who will have a published calendar,
            //and those who will not have one (terming it unpublished)
            List<UserDetails> usersPublished = new List<UserDetails>();
            List<UserDetails> usersUnpublished = new List<UserDetails>();
            foreach(var item in users)
            {
                if (item.PublishedCal == true)
                {                    
                    usersPublished.Add(item);
                }
                else
                {                    
                    usersUnpublished.Add(item);
                }
            }

            //Powershell it
            //Connect to Exchange Online Using Remote PowerShell
            //http://technet.microsoft.com/en-US/library/jj984289.aspx
            
            WSManConnectionInfo o365Connection = Office365Remoting.getOffice365Connection();

            //Enable calendar for the "checked" users
            Office365Remoting.enablePublishedCalendars(usersPublished,o365Connection);
            //Disable for the "unchecked" users
            Office365Remoting.disablePublishedCalendars(usersUnpublished, o365Connection);

            //Retrieve the urls for the users who have a public calendar
            users = Office365Remoting.getCalendarUrls(usersPublished,o365Connection);

            //When presenting the view we want to display all users
            //Users without a public calendar on the lower part of the list
            users.AddRange(usersUnpublished);

            //If we've come this far we would like to add the values to our directory
            string tenantId = ClaimsPrincipal.Current.FindFirst(TenantIdClaimType).Value;

			// 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 appObjectId = await DirectoryExtensions.getAppObjectId(tenantId, authHeader);

            string extensionName = string.Empty;

            //Check if PublishedCalendar extension is registered by trying to get the id
            extensionName = await DirectoryExtensions.checkExtensionRegistered(tenantId, authHeader, appObjectId, "PublishedCalendarUrl");

            if (extensionName == "false")
            {
                extensionName = await DirectoryExtensions.registerExtension(tenantId, authHeader, appObjectId, "PublishedCalendarUrl");
            }

            foreach (var user in users)
            {
                await DirectoryExtensions.setExtensionValue(tenantId, authHeader, user.userPrincipalName, extensionName, user.PublishedCalendarUrl);
            }

            return View(users);
        }
    }
}

The index method retrieves all users and adds them to the list returned to the view if they have a shared calendar. A tenant will most likely have users that don’t have a published calendar, don’t have Exchange, or aren’t even a native Azure AD user, (for instance Microsoft Accounts that can administrate your Azure subscription).

The PublishCalendar methods (one for GET and one for POST) triggers the PowerShell cmdlets and does the actual setting of values in the directory extension attribute.

Next we’ll scaffold a view for PublishCalendars and replace with the following (I like scaffolding because it creates the folder in the right place, and attaches the correct model, even if I don’t intend to use the boilerplate Razor):

@model IEnumerable<CalendarSharing.Models.UserDetails>

@{
    ViewBag.Title = "PublishCalendars";
}

<h2>Publish the calendars in my organization</h2>

@{
    using (Html.BeginForm())
    {
        @Html.AntiForgeryToken()

    <table class="table">
        <tr>
            <th>@Html.DisplayNameFor(model => model.displayName)</th>
            <th>@Html.DisplayNameFor(model => model.mailNickname)</th>
            <th>@Html.DisplayNameFor(model => model.PublishedCal)</th>
            <th>@Html.DisplayNameFor(model => model.PublishedCalendarUrl)</th>
            <th>@Html.DisplayNameFor(model => model.PublishedICalUrl)</th>
        </tr>

        @foreach (var item in Model.Select((value, i) => new { i, value }))
        {                        
            string indexer = "[" + item.i + "].";
            string dn = "displayName";
            string alias = "mailNickName";
            string pCal = "PublishedCal";
            string upn = "userPrincipalName";
            
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.value.displayName)
                    <input id="item_value_displayName" name="@indexer@dn" type="hidden" value="@item.value.displayName" />
                    <input id="item_value_displayName" name="@indexer@upn" type="hidden" value="@item.value.userPrincipalName" />
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.value.mailNickname)
                    <input id="item_value_mailNickname" name="@indexer@alias" type="hidden" value="@item.value.mailNickname" />
                </td>
                <td>                    
                    <input class="check-box" id="item_value_PublishedCal" name="@indexer@pCal" type="checkbox" value="true" />
                </td>
                <td>@Html.DisplayFor(modelItem => item.value.PublishedCalendarUrl)</td>
                <td>@Html.DisplayFor(modelItem => item.value.PublishedICalUrl)</td>
            </tr>
        }        
    </table>

    <div class="form-group">
        <div class="col-md-10">
            <input type="submit" value="Enable/Disable calendars" class="btn btn-default" />
        </div>
    </div>

    }
}

You might think this code looks a bit different than the defaults, and you’d be right. The defaults will be able to properly show a table and do a submit, but in this case we’re passing an array back on the submit, and this requires us to add an indexer so the values are unique. This is also the reason I’m mixing the use of html helpers, and writing the html tags myself. If you try changing it up and view the source of the html being generated you’ll quickly see why it has to be this way.

I said that we’ll be displaying the links to the calendars as QR codes, and for that I thought I’d use a neat approach From Hanselman’s blog:
http://www.hanselman.com/blog/HowToDisplayAQRCodeInASPNETAndWPF.aspx

So, add the ZXing.Net package through NuGet.

Add a file called HtmlHelperExtensions.cs to the Utils folder, and paste in this code:

using System;
using System.Collections.Generic;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using ZXing;

namespace CalendarSharing.Utils
{
    public static class HtmlHelperExtensions
    {
        public static IHtmlString GenerateCalendarQRCode(this HtmlHelper html,string calendarUrl,string alias,int height=250,int width=250,int margin=0)
        {
            var qrValue = calendarUrl;
            var barcodeWriter = new BarcodeWriter
            {
                Format = BarcodeFormat.QR_CODE,
                Options = new ZXing.Common.EncodingOptions
                {
                    Height = height,
                    Width = width,
                    Margin = margin
                }
            };

            using (var bitmap = barcodeWriter.Write(qrValue))
            using (var stream=new MemoryStream())
            {
                bitmap.Save(stream, ImageFormat.Gif);

                var img = new TagBuilder("img");
                img.MergeAttribute("alt", alias + "'s calendar");
                img.Attributes.Add("src", String.Format("data:image/gif;base64,{0}",
                    Convert.ToBase64String(stream.ToArray())));

                return MvcHtmlString.Create(img.ToString(TagRenderMode.SelfClosing));
            }
        }
    }
}

I based myself on Hanselman’s sample, but made a couple of minor edits to better suit my needs.

Now we can edit the Index view:

@model IEnumerable<CalendarSharing.Models.UserDetails>
@using CalendarSharing.Utils;
@{
    ViewBag.Title = "Home Page";
}

<div class="jumbotron">
    <h1>Contoso Calendar Service</h1>
    <p class="lead">Scan the QR codes below to check our calendars.</p>   
</div>

@if (ViewBag.UserCount == 0)
{
    <p>Seems like you've either not shared any calendars, or your directory extensions aren't working.</p>
}
@if (ViewBag.UserCount > 0)
{
    <table class="table table-striped table-condensed">
        @foreach (var item in Model)
        {
            <tr>
                <td>@Html.DisplayFor(modelItem => item.mailNickname)'s Calendar</td>
                <td>@Html.GenerateCalendarQRCode(item.PublishedCalendarUrl,item.mailNickname)</td>                
            </tr>
        }
    </table>
}

Not exactly super-fancy, but we print a table with the username, and dynamically create a QR code on the fly based on the calendar url stored in our Azure Active Directory.

You’ll notice that I have the Authorize attribute on the Index action, but that’s mainly because I use it to find the tenant id of the current user to know which Azure AD tenant to get calendars from. If you hardcode that value, or look it up in a table or something you could easily remove Authorize and have a view that anonymous users can access.

With that we’re basically at the end of this sample. While the intent was to show the usefulness of Directory Extensions in Azure Active Directory we’ve also seen how to mess around with Office 365 data and how to make things more readily available in the process.

Since the sample felt closely related to the repo I already created on GitHub I’ve added it to the same solution, and you can download it if you don’t feel like going through the motions of copy & paste from this article:
https://github.com/ahelland/DirectoryExtensions

Leave a Reply

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

*