Office 365 Groups Explorer, now updated with Connectors

A while back, I submitted a sample to the Office Dev PnP repo that exercised the Office Graph to Explore Office 365 Groups. The application is also available online at https://office365groupsexplorer.azurewebsites.net/. Today, I am excited to announce that the online version now includes a page to exercise the Office 365 Connectors!


In this article, I want to walk thru the code to register the connector with a group. Although it is no too difficult, there are numerous steps that have to occur. And, any website that is connecting to Office 365 Groups will require some persistent storage.

The first step -- register your application. There is a page to do so at http://go.microsoft.com/fwlink/?LinkID=780623.

The connector registration in its current format does not provide for multiple URLs for the callback. This means that you need to register a separate connector for DEV/TEST/PROD environments. (When registering an application in Azure AD, you can provide multiple Reply URLs.) I submitted a UserVoice entry for this -- please vote it up!

Once your connector is registered, we can get started. The HTML snippet provided by the registration page contains all the markup we need. However, before users click that button, we need to setup some stuff.

We can pass state information to Office 365 as part of the request. The same state value will be returned to us as part of the callback. So, we should generate that state, save it, and compare the value returned. If we don't recognize the state value, then someone is sending us traffic we don't expect.

In my application, I am storing a list of registered connectors by Tenant ID. The first time a connector is registered, I generate a GUID and use that for the state value.

// get any connectors for this tenant...
var upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
var user = repo.GetUser(upn);
string tenantId = user.TenantId;

var connectorEntity = repo.GetConnector(tenantId);

if (connectorEntity == null)
{
  // Create a unique state value and save it for verification later
  connectorEntity = new ConnectorEntity(tenantId);
  repo.AddOrUpdateConnector(connectorEntity);
}

connector.RegistrationStateValue = connectorEntity.RegistrationStateValue;

Now that the state value is generated and stored, I use it to validate the callback. If the callback contains a state value that I recognize, I store the the webhook URL and group name. I will need the webhook URL to send cards.

public ActionResult Callback(string state, string group_name, string webhook_url, string error)
{
  try
  {
    // the state value should be in our table as a tenant id
    Guid stateValue = Guid.Parse(state);
    var connectorEntity = repo.GetConnectorByStateValue(stateValue);

    if (connectorEntity == null)
    {
      Response.Redirect("/Home/Error?err=3");
    }

    List<Webhook> webhooks = new List<Webhook>();
    Webhook webhook = null;

    if (!String.IsNullOrEmpty(connectorEntity.Webhooks))
    {
      webhooks = JsonConvert.DeserializeObject<List<Webhook>>(connectorEntity.Webhooks);
      webhook = webhooks.FirstOrDefault(w => w.GroupName.Equals(group_name));
    }

    if (webhook == null)
    {
      webhook = new Webhook(group_name, webhook_url);
      webhooks.Add(webhook);
    }
    else
    {
      webhook.WebhookUrl = webhook_url;
    }

    connectorEntity.Webhooks = JsonConvert.SerializeObject(webhooks);
    repo.AddOrUpdateConnector(connectorEntity);

    ViewBag.status = "Success";
    ViewBag.GroupName = group_name;
  }
  catch (Exception ex)
  {
    ViewBag.status = ex.Message;
  }
  return View();
}

So, once the Office 365 service issues the callback, the connector webhook URL is saved. I can retrieve it by Tenant ID and group name. Since my web application requires logins from Azure AD, I can get the Tenant ID of the current user. And I present a user interface that allows the selection of the group by name. So I can retrieve the URL and make the POST containing the information for the card.

[Authorize]
[HttpPost]
public async Task<ActionResult> Index(Connector model)
{
  Connector connector = new Connector();
	connector.WebHooks = new List<Webhook>();

  try
  {
    // get connectors for this tenant...
    var upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
    var user = repo.GetUser(upn);
    string tenantId = user.TenantId;

    var connectorEntity = repo.GetConnector(tenantId);

    if (connectorEntity == null)
    {
      // if no connectors stored, do nothing
      return View(connector);
    }

    connector.RegistrationStateValue = connectorEntity.RegistrationStateValue;

    if (String.IsNullOrEmpty(connectorEntity.Webhooks))
    {
      Warning("No connectors found for domain.");
      return View(connector);
    }

    connector.WebHooks = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Webhook>>(connectorEntity.Webhooks);

    if (!ModelState.IsValid)
    {
      Danger("Please complete all required fields.");
      return View(connector);
    }

    var webhook = connector.WebHooks.FirstOrDefault(w => w.GroupName.Equals(model.Group));
    if (webhook == null)
    {
      Warning("No connector found for group.");
      return View(connector);
    }

    // Create the Connector Card payload
    var card = new ConnectorCard
    {
      Text = model.Text,
      Title = model.Title,
    };

    if (!String.IsNullOrEmpty(model.ActionText) &&
        !String.IsNullOrEmpty(model.ActionTarget))
    {
      Uri uri = null;
      if (Uri.TryCreate(model.ActionTarget, UriKind.RelativeOrAbsolute, out uri))
      {
        ViewAction viewAction = new ViewAction
        {
          Name = model.ActionText,
          Target = new string[] { model.ActionTarget }
        };
        card.PotentialAction = new ViewAction[] { viewAction };
      }
    };

    var requestBody = JsonConvert.SerializeObject(card, null, new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() });

    // Make POST to webhook URL
    var status = await Utils.HttpHelper.PostJsonMessage(webhook.WebhookUrl, requestBody);
    if (status)
    {
      Success("Card posted successfully", true);
      return RedirectToAction("Index");
    }
  }
  catch (Exception ex)
  {
    Danger(ex.Message);
  }
	return View(connector);
}

Again, this connector is live on the site at https://office365groupsexplorer.azurewebsites.net/. If you are an administrator of a tenant, I encourage you to check it out!