Click here to Skip to main content
15,885,546 members
Articles / Hosted Services / Azure

Building an OAuth Yammer App with ASP.Net MVC and Web API

Rate me:
Please Sign up or sign in to vote.
4.89/5 (6 votes)
4 Mar 2015CPOL11 min read 32K   321   11   5
In this article, we'll build an ASP.Net MVC 5 web app which will search your Yammer feed for a hashtag, and display the poster's home town on a Bing map

 

Introduction

In this article, we'll build an ASP.Net MVC 5 web application which will search your Yammer feed for a particular hashtag, and and then use Yammer's graph search to fetch the information about the poster's home town. We'll make a ASP.Net WebAPI endpoint to serve our information, and use JQuery and Bing Maps to display that information to our end users.

Image 1

Background

Internally, my company uses Yammer as its social network. When I went to a company-sponsored tech conference last summer, they suggested that we use a particular hashtag for all of our posts. Since we had people literally flying in from around the world to attend the conference, I thought it would be cool to build an app which showed the posts which included the suggested hashtag as a highlighted where everyone was from.

 

This article is rather long, so I've separated it into two parts:

  1. Prerequisites
  2. Code

Prerequisites

Before we dive into the code, we need to get the following prerequisites out of the way

  1. Hosting
  2. Database
  3. Maps API Key
  4. Yammer API Key

Hosting

The first thing we're going to need to do is set up some hosting that supplies HTTPS - Microsoft Azure websites are currently offering 10 sites and SSL on their *.azurewebsites.net domain for free, so I'm going with that. You don't have to use Azure, you just need your host to support HTTPS. Everything in this article past this point will not work if you don't have SSL/HTTPS up and running.

If you use Azure, go to https://manage.windowsazure.com, log in with your microsoft account, and navigate to the add website wizard. You can use the Quick Create option here, and they make it pretty easy. The important thing is to reserve a URL that we can reference later.

Image 2

Database

Upon authenticating users, we'll want a record that they visited - having engagement metrics helps ensure that as we add features and further develop this app in the future, we're going in the right direction. Since I've already got hosting set up in Azure websites, I'm going to utilize SQL Azure for my database. Doing so makes it really easy to integrate with Entity Framework Code First (which we'll be using later). Since we'll only be using the database to record who used the app and nothing else, this step is optional. Doing this in Azure is pretty easy. Just use the quick create for this step.

Image 3

NOTE: When using SQL Azure as your database host, you'll need to make sure that the firewall rules are set up so that you can connect to the database from your current location. After the wizard completes its initialization, take a minute to click the "Manage" button at the bottom to launch the silverlight manager at the bottom of the page - this will open a prompt which will get the firewall rules set up for you.

Maps

The next thing we're going to need to do is get set up with a Bing Maps key. If you take a look throught the documentation for the Bing Maps AJAX control, you'll see that in order to use the site maps widget, you need an API key. 

Start off by going to https://www.bingmapsportal.com and signing in with your Microsoft account. Once there, click on the link to create a key, and then select "Basic" for the key type and "Public Website" for the application type. If you choose "Trial" for the key type, then the key that you generate will only be good for 90 days.

NOTE: In box for the application URL, we're using the HTTPS version of the URL we reserved on our hosting

Image 4

After creating the key, click on the "Create or view keys" link, then scroll down to the bottom.

Image 5You can see in my example, I started by creating a trial key, and then later created a basic key which has no expiration date.

Write down/save the key that Bing Maps generates for you. We'll need this information later.

Yammer

Head over to the client application management area, and click on "Register New App" Take a look at Yammer's API introduction, it's some pretty good stuff. 

Image 6

Fill out the required information, we can change all but the application name later. For now, supply the reserved HTTPS url (https://hashmaps.azurewebsites.net/) for the website, and https://hashmaps.azurewebsites.net/Home/Display for the redirect URI

  • The Website value is where users will be redirected to in the event they look up your application's company profile in yammer
  • The Redirect URI is the endpoint yammer will redirect your users to in the event they successfully authenticate with Yammer.

Once you've filled out the information, and completed the registration,  head over to the "My Apps" area, and write down/save the client ID and client secret that yammer generated for you. We'll need this information for later.

Image 7

 

Code

The code has the following sections:

  1. Setting up the solution
  2. Authentication
  3. OAuth module
  4. Our Yammer API
  5. JavaScript constants
  6. Login Page
  7. Maps Page
  8. Cycling the posts

Setting up the Solution

Start off by creating a new empty web project in Visual Studio. From there, add the following NuGet packages:

  • Bootstrap
  • EntityFramework
  • Microsoft ASP.NET MVC
  • Microsoft ASP.Net Web API 2.1
  • Microsoft ASP.Net Web Optimization Framework

Then, add the following projects

  • HashMaps.Data (this is where we will add our database objects)
    • Referencing EntityFramework 
  • HashMaps.Model (this is where our DTO objects will live)
  • Modules (this is where we will host our OAuth logic)
    • Referencing EntityFramework and Json.Net

Finally,

  • In your HashMaps project, add HashMaps.Data, HashMaps.Model, and Modules as project references
  • HashMaps.Data project references HashMaps.Model
  • Modules project references HashMaps.Data and HashMaps.Model.

When you're finished, your project structure should look as follows:

Image 8

 

Authentication

In your HashMaps.Model project, let's start by declaring our core IPrincipal and IIdentity objects. These objects will serve as our "application" level identity, and we'll internally use them to define "who" a user is. It is these objects that we'll be attaching Yammer authentication to.

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;

namespace HashMaps.Model
{
    public class UserPrincipal : IPrincipal
    {
        public UserPrincipal(UserIdentity identity)
        {
            this.Identity = identity;
        }

        public UserIdentity Identity { get; set; }

        IIdentity IPrincipal.Identity { get { return Identity; } }

        public bool IsInRole(string role)
        {
            return true;
        }
    }

    public class UserIdentity : IIdentity
    {
        public String AuthToken { get; set; }
        public int ID { get; set; }

        public string AuthenticationType
        {
            get { return "OAuth"; }
        }

        public bool IsAuthenticated
        {
            get { return true; }
        }

        public string Name { get; set; }
    }
}

Notice that we're unconditionally returning true for the IsInRole method - that's because for purposes of this application, we're not introducing the concept of tiered (normal/admin) accounts. Everybody has access to everything.

Additionally, we're unconditionally returning true on the IsAuthenticated property in the UserIdentity class, because (again) for purposes of this application, we won't create an instance of a UserIdentity if we fail to authenticate the user.

Notice here that we're also adding an AuthToken property to the UserIdentity. Since we'll be making subsequent calls to Yammer after authentication (and their API requires this value), we'll keep this value in memory so we don't have to incur an unnecessary performance hit to retrieve this value a subsequent time.

OAuth Module

The idea here is that we're going to create a custom HTTP module which constructs our IPrinciple and IIdentity objects, and then attach that user to Context.User. When the HTTP request pipeline begins executing authentication and authorization code, it'll use our constructed objects as the basis for determining whether or not to continue. If you want a deeper explanation on how this works, 4Guys from Rolla has an excellent article which explains the theory in detail.

Let's start by looking at the first part of the context_AuthenticateRequest method:

C#
var app = sender as HttpApplication;

if (app.Context.User != null)
{ return; } //user has alerady been authenticated.

if (app.Request.Cookies.Keys.Cast<String>().Contains(System.Web.Security.FormsAuthentication.FormsCookieName))
{
     //the user is re-visiting within the same 'session'.
     //attempt to extract the information from the encrypted ticket
                
     try 
     {
          var authCookie = app.Request.Cookies[System.Web.Security.FormsAuthentication.FormsCookieName];
          var ticket = System.Web.Security.FormsAuthentication.Decrypt(authCookie.Value);

          if(ticket.Expired == false)
          {
               var userID = Convert.ToInt32(ticket.Name);
               var authtoken = ticket.UserData;
               app.Context.User = new UserPrincipal(new UserIdentity() { AuthToken = authtoken, ID = userID });
               return;
          }                    
      }
      catch (Exception ex)
      { }                
}

 

Here, we're just looking to make sure that we've already authenticated the user. That is, if the context aready has a user instance accociated with it, then skip the rest, and if the request comes in with a valid forms authentication ticket in its cookie collection, convert it to a user, associate it with the context, and return.

The second part is where we do the cool stuff:

C#
//the user has authorized the third party to utilized the app.
//now attempt to authenticate them.
if (String.IsNullOrWhiteSpace(app.Request.Params.Get("code")) == false)
{
    using (var cli = new WebClient())
    {
        var authorizeCode = app.Request.Params.Get("code");
        var yammerClientID = ConfigurationManager.AppSettings["YammerClientID"];
        var yammerClientSecret = ConfigurationManager.AppSettings["YammerClientSecret"];
        var authenticateUrl = String.Format("https://www.yammer.com/oauth2/access_token.json?client_id={0}&client_secret={1}&code={2}", yammerClientID, yammerClientSecret, authorizeCode);
        var authenticationResponse = cli.DownloadString(authenticateUrl);
        var authenticationObject = JsonConvert.DeserializeObject<AuthenticationResult>(authenticationResponse);
        var authenticationToken = authenticationObject.access_token.token.ToString();
        using (var db = new HashMapContext())
        {
                        
            var user = db.Users.Where(u => u.ID == authenticationObject.user.id).FirstOrDefault();
            if(user == null)
            { //this is a brand new user. create them.
                user = db.Users.Create();
                user.ID = authenticationObject.user.id;
                user.Location = authenticationObject.user.location;
                user.MugshotUrl = authenticationObject.user.mugshot_url;
                user.Name = authenticationObject.user.full_name;
                user.AuthorizationCode = authorizeCode;
                user.AuthenticationToken = authenticationToken;
                db.Users.Add(user);
            }
            else
            { //we've seen this user before, update their authentication token.
                user.AuthenticationToken = authenticationToken;
            }
            db.SaveChanges();
        }

        var expirationDate = DateTime.UtcNow.AddHours(1);
        var ticket = new System.Web.Security.FormsAuthenticationTicket(1, authenticationObject.user.id.ToString(), DateTime.UtcNow, expirationDate, true, authenticationToken);
        var cookieString = System.Web.Security.FormsAuthentication.Encrypt(ticket);
        var authCookie = new HttpCookie(System.Web.Security.FormsAuthentication.FormsCookieName, cookieString);
        authCookie.Expires = expirationDate;
        authCookie.Path = System.Web.Security.FormsAuthentication.FormsCookiePath;
        app.Response.Cookies.Set(authCookie);
                                        
        app.Context.User = new UserPrincipal(new UserIdentity() { ID = authenticationObject.user.id, AuthToken = authenticationToken });                    
    }
    return;
}

When Yammer successfully authenticates your user, and the user authorizes your app, yammer will redirect back to your application with a code parameter in the url. Using the user authorization code, your Yammer client ID, and your Yammer client secret, you can then make API calls to Yammer to get information about your newly authenticated user.  

After we extract a bit of information about the user and save it off, we convert that information into a UserPrincipal and UserIdentity, and then assign it to app.Context.User.

Make sure that this project is referenced in your HashMaps.Web project, and then head over to the root level web.config. Under the System.WebServer node, ensure the following is present:

XML
<modules>
    <add name="OAuthModule" type="Modules.OAuthModule, Modules" />
</modules>

 

Our own Yammer API

Lets head over now to the web project and add a Web API endpoint. In the Get method (which is protected by the [Authorize] attribute), we'll start by extracting the auth token from the context's current user, and making a api call to search Yammer for "AvaTS14".

C#
var user = this.User as UserPrincipal;
String searchResults = null;
var authToken = user.Identity.AuthToken;

using (var cli = new WebClient())
{
    cli.Headers.Add("Authorization", "Bearer " + authToken);
    cli.QueryString.Add("search", "AvaTS14");

    try
    {
        searchResults = cli.DownloadString("https://www.yammer.com/api/v1/search.json");
    }catch(Exception ex)
    {
        return new List<HashMaps.Model.Dto.DtoMessage>();
    }//TODO: add logging. we might have gotten rate limited by the api
}

var feed = JsonConvert.DeserializeObject<RootObject>(searchResults);

Notice how we were able to cast this.User to a UserPrincipal? That's only because we explicitly put an [Authorize] attribute on the method (rejecting anonymous requests), and injected our custom authentication module into the HTTP pipeline.

Now, the raw result that comes back from Yammer includes a lot of things that we don't need for purposes of this application. We'll then turn this into a collection of DTOs (data transfer objects):

C#
var dtos = new List<HashMaps.Model.Dto.DtoMessage>();
                           
foreach (var msg in feed.messages.messages)
{
    try
    {                        
        var dto = new HashMaps.Model.Dto.DtoMessage();
        dto.ID = msg.id;
        dto.PlainBody = msg.body.plain;
        dto.WebUrl = msg.web_url;
        dto.Composer = new HashMaps.Model.Dto.DtoPerson() { ID = msg.sender_id };
        dtos.Add(dto);
    }
    catch (Exception ex)
    { } //add logging later
}

Now that we have the message we'll display, we need to find out where the composer is from.

C#
var distinctSenderIDs = feed.messages.messages.Select(m => m.sender_id).Distinct();
var distinctSenders = new List<Model.Dto.DtoPerson>();
using (var db = new HashMapContext())
{
    foreach (var senderID in distinctSenderIDs)
    {
        //first look in the database to see if we know about this person
        var dbSender = db.Users.Where(u => u.ID == senderID).FirstOrDefault();
        if (dbSender != null)
        {
            //jackpot, now copy it over
            var sender = new Model.Dto.DtoPerson();
            sender.FullName = dbSender.Name;
            sender.ID = dbSender.ID;
            sender.Location = dbSender.Location;
            distinctSenders.Add(sender);
            continue;
        }
        
        using (var cli = new WebClient())
        {
            cli.Headers.Add("Authorization", "Bearer " + authToken);
            try
            {
                var userJson = cli.DownloadString("https://www.yammer.com/api/v1/users/" + senderID.ToString() + ".json");
                var parsedUser = JsonConvert.DeserializeObject<Person>(userJson);
                var sender = new Model.Dto.DtoPerson();
                sender.FullName = parsedUser.full_name;
                sender.ID = parsedUser.id;
                sender.Location = parsedUser.location;
                distinctSenders.Add(sender);
            }
            catch (Exception ex)
            { continue; } //add logging later
        }
    }
}

First, we're going to look in our own database for the person before reaching out to Yammer for the person. The desired field here is location. If we were going to scale this for larger use, we might modify this so that we see when the last time we pulled location, as people tend to move from city to city - This application was only designed to be used for a few days, so the idea behind hitting the DB was we'll take a performance hit on our DB before we bother Yammer asking for the location of a person that we pulled earlier that day.

Now that we have all of the data we're going to return to our caller, let's merge it into a usable DTO

C#
foreach (var dto in dtos)
{
    //find the composer
    var composer = distinctSenders.Where(s => s.ID == dto.Composer.ID).FirstOrDefault();
    if (composer != null)
    {
        dto.Composer = composer;
    }
}

//only return yams that have location. We don't want to push out stuff we can't see.
return dtos.Where(dto => String.IsNullOrWhiteSpace(dto.Composer.Location) == false);

One last thing we want to do for our API consumers is clean up the feed so that we omit posts where the user didn't specify a location. The whole point of this is to render the posts on a map, and if there's no location data, then the post is essentially worthless to us.

JavaScript Constants

I'm not exactly sure if it's considered "good" practice or not, but one thing I like to do is create a constants object on the client side which keeps track of all of the configuration item's we'll need from page to page.

In the Layout.cshtml page, I add the following:

ASP.NET
@Scripts.Render("~/bundles/Frameworks")

<script type="text/javascript">
    var constants = {
        yammerClientID: "@System.Configuration.ConfigurationManager.AppSettings["YammerClientID"]",
        bingMapsKey: "@System.Configuration.ConfigurationManager.AppSettings["BingMapsKey"]",
        host: "@String.Format("{0}://{1}", Request.Url.Scheme, Request.Url.Authority)"            
    };
</script>

@RenderSection("scripts", required: false)

This pulls the Yammer client ID and the Bing Maps key from our web.config, and makes them available as javascript variables. I also push the host forward, as the Yammer redirect needs this value to match with the value we set up in the API key profile.

Login Page

On the login page, you can use a button design that yammer provides, or you can make your own. The key here is the following code:

JavaScript
(function () {
    $("#logIn").click(function () {        
        var redirect = encodeURIComponent(constants.host + "/Home/Display");
        window.location = "https://www.yammer.com/dialog/oauth?client_id=" + constants.yammerClientID + "&redirect_uri=" + redirect;
    });
});

This tells your app to redirect to Yammer for authentication, and upon successful authentication, come back to the Home/Display controller.

The Maps Page

Finally, on the page which actually renders the map, let's start off by setting up the bing map to associate with a div:

JavaScript
var map;
var searchManager;

var options = {
  credentials: constants.bingMapsKey,
  center: new Microsoft.Maps.Location(35.333333, 25.133333),
  zoom: 2
};

$("#mapDiv").text("");
var map = new Microsoft.Maps.Map(document.getElementById("mapDiv"), options);

Technically, it's not necessary to center the map - Through trial and error I found a place between South America and Africa which defaults the map so that North and South America fits on the left, and Asia, Europe, and Africa fit on the riight, and Antartica is visible and centered on the bottom.

Next, we need to define a call back when the map is ready to search and perform geocoding operations:

JavaScript
Microsoft.Maps.loadModule('Microsoft.Maps.Search', { callback: searchModuleLoaded });

function searchModuleLoaded() {
    searchManager = new Microsoft.Maps.Search.SearchManager(map);
}

 

Cycling the posts

The basic idea here is that we'll declare an array of posts, cycle through them one at a time, and then periodically refresh the array with new values from the API that we created.

First, let's write the code to do a refresh on the array that we're cycling through:

JavaScript
var yams = [];

var currentYamIndex = -1;
var currentPin = null;
var infobox = null;   

refreshYams();

window.setInterval(function () {
    refreshYams();
}, 60 * 2 * 1000);

function refreshYams()
{
    $("#loading-placeholder").show();

    $.ajax({
        url: "/Api/YammerUpdates",
        success: function (data, textStatus, jqXHR) {
            $("#loading-placeholder").hide();
            if (data.length > 0)
            { yams = data; } //only refresh the yams if there's new ones.
        },
        error: function (jqXHR, textStatus, errorThrown) {
            alert("Error");
        }
    });
}

It's not very sexy, but every 2 minutes, we're just showing the loading place holder, issuing a HTTP get to the api to pull the new data, and then hiding the placeholder once we've retrieved the data.

Now let's cycle through them:

JavaScript
window.setInterval(function () {
   cycleYams();
}, 5 * 1000);

function cycleYams() {
    if (searchManager == undefined || searchManager == null)
    { return; }

    if (yams.length == 0)
    { return; }

    if (currentYamIndex + 1 > yams.length - 1)
    { currentYamIndex = 0; }
    else
    { currentYamIndex++; }

    PlotData(yams[currentYamIndex]);
}

Again, nothing special, just moving the current iterator forward once every 5 seconds, and then looping back to the beginning once we hit the end.

The real magic happens when we plot the post:

JavaScript
function PlotData(item)
{
    var geocodeRequest = { where: item.Composer.Location, count: 1, callback: geocodeCallback, errorCallback: errCallback, userData: item };
    searchManager.geocode(geocodeRequest);
}

function geocodeCallback(geocodeResult, userData) {

    if (currentPin != null)
    {
        map.entities.remove(currentPin); //clear the existing pin, if necessary
    }

    if (geocodeResult.results.length > 0)
    {
        var location = geocodeResult.results[0].location;           

        currentPin = new Microsoft.Maps.Infobox(location, {
            visible: true,
            title: userData.Composer.FullName,
            description: userData.PlainBody.substr(0,140)
        });
            
        map.entities.push(currentPin);
    }
}

function errCallback(geocodeRequest) {
  //  alert("An error occurred.");
}

The first thing we do here is remove the existing pin - we *could* have two posts showing at the same time, but it makes for a bad looking UI.

When we drop a pin on the map, it HAS to be by latitude and longitude. In our Yammer profile, we don't get that - we get "San Antonio, Texas". So we issue a geocode request, and when it successfully comes back, it gives us the latitude and longitude which we can then pass onto an infobox that gets dropped on the map.

 

Points of Interest

I had a lot of fun building this application. My next steps are to migrate this to the OWIN application structure, allow users to search by a custom hashtag, and search their Facebook, Twitter, and LinkedIn feeds, as well as Yammer

History

2014-11-19 : Fixed some spelling errors

2014-11-18 : Original post

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United States United States
Matthew is a software developer currently living and working in San Antonio Texas. When not working on software, Matthew enjoys spending time in his backyard garden.

Comments and Discussions

 
BugWebException was caught:The remote server returned an error: (403) Forbidden. Pin
Rupesh Gonte26-Apr-15 23:07
Rupesh Gonte26-Apr-15 23:07 
GeneralRe: WebException was caught:The remote server returned an error: (403) Forbidden. Pin
MatthewThomas29-Apr-15 3:42
MatthewThomas29-Apr-15 3:42 
GeneralRe: WebException was caught:The remote server returned an error: (403) Forbidden. Pin
Rupesh Gonte7-May-15 2:30
Rupesh Gonte7-May-15 2:30 
GeneralMy vote of 5 Pin
Humayun Kabir Mamun18-Nov-14 18:53
Humayun Kabir Mamun18-Nov-14 18:53 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.