Using DotNetOpenAuth to Authenticate Against StackOverflow in an MVC 4 Application

Today I wanted to learn how to implement oAuth in as small a foot print as possible. I’ve written my own custom oAuth client before, but it was very bulky and not very reusable. Since DotNetOAuth ships with all .NET 4.0 MVC Web Applications (and I assume WebForms as well), I thought I’d look into writing against it. I wasn’t able to find much in the way of existing documentation or tutorials, so I’m posting my results for you. My examples are written for StackOverflow, but the same idea should apply to any OAuth provider.

We’re going to need three classes. They’ll be tightly coupled, so feel free to put them in a single file (as I did). Two of these classes are data contracts, and the third moves your data around. Let’s start with the data contracts.

They should simply mirror the json/xml result you expect to receive. StackOverflow throws a lot of data back on a simple user lookup request. Here’s what their JSON looks like:

{
  "items": [
    {
      "user_id": 1284102,
      "user_type": "registered",
      "creation_date": 1332351699,
      "display_name": "Billdr",
      "profile_image": "http://www.gravatar.com/avatar/30a850dc91432d86e940e4788a3f1c42?d=identicon&r=PG",
      "reputation": 536,
      "reputation_change_day": 0,
      "reputation_change_week": 10,
      "reputation_change_month": 10,
      "reputation_change_quarter": 10,
      "reputation_change_year": 10,
      "age": 30,
      "last_access_date": 1357764683,
      "last_modified_date": 1355755828,
      "is_employee": false,
      "link": "http://stackoverflow.com/users/1284102/billdr",
      "website_url": "http://www.billdlabs.com/",
      "location": "Minneapolis, MN",
      "account_id": 1342727,
      "badge_counts": {
        "gold": 0,
        "silver": 1,
        "bronze": 16
      },
      "accept_rate": 92
    }
  ],
  "quota_remaining": 9993,
  "quota_max": 10000,
  "has_more": false
}

So how do we model all of that as C# classes? Well, you’ll notice the data we actually want is wrapped up in an array called “items.” So, we need to create a class to wrap the data we actually want. For completeness, I also included the quota information at the end.

    [DataContract]
    class StackOverflowJsonReturns
    {
        [DataMember(Name = "items")]
        public IEnumerable<StackOverflowClientData> Users { get; set; }

        [DataMember(Name = "quota_remaining")]
        public int QuotaRemaining { get; set; }

        [DataMember(Name = "quota_max")]
        public int QuotaMax { get; set; }

        [DataMember(Name = "has_more")]
        public bool HasMore { get; set; }
    }

You’ll see I wrapped the actual data in it’s own class, “StackOverflowClientData.” Here’s what that looks like.

    [DataContract]
    class StackOverflowClientData
    {
        [DataMember(Name = "user_id")]
        public int Id { get; set; }

        [DataMember(Name = "user_type")]
        public string SOUserType { get; set; }

        [DataMember(Name = "creation_date")]
        public int SOJoinDate { get; set; }

        [DataMember(Name = "display_name")]
        public string DisplayName { get; set; }

        [DataMember(Name = "profile_image")]
        public Uri ProfileImage { get; set; }

        [DataMember(Name = "reputation")]
        public int Reputation { get; set; }

        [DataMember(Name = "reputation_change_day")]
        public int ReputationChangeDay { get; set; }
        
        [DataMember(Name = "reptutation_change_week")]
        public int ReputationChangeWeek { get; set; }

        [DataMember(Name="reputation_change_month")]
        public int ReputationChangeMonth { get; set; }

        [DataMember(Name="reputation_change_quarter")]
        public int ReputationChangeQuarter { get; set; }

        [DataMember(Name = "reputation_change_year")]
        public int ReputationChangeYear { get; set; }

        [DataMember(Name = "age")]
        public int Age { get; set; }

        [DataMember(Name = "last_access_date")]
        public int LastAccessDate { get; set; }

        [DataMember(Name = "last_modified_date")]
        public int LastModifiedDate { get; set; }

        [DataMember(Name = "is_employee")]
        public bool SOEmployee { get; set; }

        [DataMember(Name = "link")]
        public Uri SOPage { get; set; }

        [DataMember(Name = "website_url")]
        public Uri PersonalPage { get; set; }

        [DataMember(Name = "location")]
        public string Location { get; set; }

        [DataMember(Name = "account_id")]
        public int SOAccountId { get; set; }

        [DataMember(Name = "badge_counts")]
        public IEnumerable<KeyValuePair<string,int>> BadgeCount { get; set; }

        [DataMember(Name="accept_rate")]
        public int AcceptRate { get; set; }
    }

This class made me want to hire someone to do data entry. 99% of the reason you’re reading this code is because I don’t want you to suffer like I suffered, creating that class. Now that the data management is out of the way, here’s the code that does the work.

    public class SEoAuthClient : OAuth2Client
    {
        #region Constants and Fields
        private const string AuthorizationEndpoint = "https://stackexchange.com/oauth";
        private const string TokenEndpoint = "https://stackexchange.com/oauth/access_token";
        private readonly string appId;
        private readonly string appSecret;
        private readonly string key;
        #endregion

        #region Constructors and Destructors

        /// The public constructor for a new instance of theSEoAuthClient class.
        public SEoAuthClient(string appId, string appSecret, string key)
            : this("Stack Overflow", appId, appSecret, key)
        {
        }

        /// Initializes a new instance of the SEoAuthClient class.
        protected SEoAuthClient(string providerName, string appId, string appSecret, string key)
            : base(providerName)
        {
            if (!string.IsNullOrEmpty(appId))
            {
                this.appId = appId;
            }
            else
            {
                throw new Exception("Missing required data in appId when calling SEoAuth.");
            }
            if (!string.IsNullOrEmpty(appSecret))
            {
                this.appSecret = appSecret;
            }
            else
            {
                throw new Exception("Missing required data in appSecret when calling SEoAuth.");
            }
            if (!string.IsNullOrEmpty(key))
            {
                this.key = key;
            }
            else
            {
                throw new Exception("Missing required data in key when calling SEoAuth.");
            }
        }
        #endregion

        /// Gets the id for this client as it is registered with SE.
        protected string AppId
        {
            get { return this.appId; }
        }

        #region Methods
        /// Builds the URL the user will be directed to, with queries.
        protected override Uri GetServiceLoginUrl(Uri returnUrl)
        {
            var builder = new UriBuilder(AuthorizationEndpoint);
            builder.Query = "client_id=" + this.appId + "&redirect_uri=" + HttpUtility.UrlEncode(returnUrl.AbsoluteUri);
            return builder.Uri;
        }

        /// Sends a request with the access token to fetch the user's data. 
        /// This is actually the last method to be called in the flow.
        protected override IDictionary<string, string> GetUserData(string accessToken)
        {
            StackOverflowClientData graph;
            //SE returns the user data as a JSON formatted string, compressed with gzip. For my sanity's sake we're going after it with a WebClient instead of a WebRequest.
            using (var client = new WebClient())
            { 
                //SE requests this be set, even if it 'fails back' to gzip anyway. We're nice folks.
                client.Headers[HttpRequestHeader.AcceptEncoding] = "gzip";   
                client.Headers[HttpRequestHeader.ContentType] = "application/json; charset=utf-8;";
                var data =
                    client.DownloadData("https://api.stackexchange.com/2.1/me?site=stackoverflow&key=" +
                                        HttpUtility.UrlEncode(key) + "&access_token=" +
                                        HttpUtility.UrlEncode(accessToken));

                string jsonString = string.Empty;

                //this block decompresses the result and turns it into a string. 
                using (var gzipStream = new GZipStream(new MemoryStream(data), CompressionMode.Decompress))
                {
                    const int size = 4096;
                    var buffer = new byte[size];
                    using (var memory = new MemoryStream())
                    {
                        int count = 0;
                        do
                        {
                            count = gzipStream.Read(buffer, 0, size);
                            if (count > 0)
                            {
                                memory.Write(buffer, 0, count);
                            }
                        } while (count > 0);
                        //failing to return the position to 0 generates an obnoxious error.
                        memory.Position = 0;
                        //DotNetOpenAuth uses DataContractJsonSerializer, so that's what we're doing
                        var des = new DataContractJsonSerializer(typeof(StackOverflowJsonReturns));
                        var allData = (StackOverflowJsonReturns) des.ReadObject(memory);
                        graph = allData.Users.First();
                    }
                }
            }
            //Now we take all of our carefully formatted data and toss it into a string, string dictionary.
            var userData = new Dictionary<string, string>();
            userData.Add("id", graph.Id.ToString());
            userData.Add("username", graph.DisplayName);
            userData.Add("name", graph.DisplayName);
            userData.Add("link", graph.SOPage.ToString());
            userData.Add("user_type", graph.SOUserType);
            userData.Add("creation_date", graph.SOJoinDate.ToString());
            userData.Add("profile_image", graph.ProfileImage.ToString());
            userData.Add("reputation", graph.Reputation.ToString());
            userData.Add("reputation_change_day", graph.ReputationChangeDay.ToString());
            userData.Add("reputation_change_week", graph.ReputationChangeWeek.ToString());
            userData.Add("reputation_change_month", graph.ReputationChangeMonth.ToString());
            userData.Add("reputation_change_quarter", graph.ReputationChangeQuarter.ToString());
            userData.Add("reputation_change_year", graph.ReputationChangeYear.ToString());
            userData.Add("age", graph.Age.ToString());
            userData.Add("last_access_date", graph.LastAccessDate.ToString());
            userData.Add("last_modified_date", graph.LastModifiedDate.ToString());
            userData.Add("website_url", graph.PersonalPage.ToString());
            userData.Add("location", graph.Location);
            userData.Add("account_id", graph.SOAccountId.ToString());
            foreach (KeyValuePair<string, int> kvp in graph.BadgeCount)
            {
                userData.Add(kvp.Key, kvp.Value.ToString());
            }
            userData.Add("accept_rate", graph.AcceptRate.ToString());

            return userData;
        }

        /// Trades the authorization code in for an access token.
        protected override string QueryAccessToken(Uri returnUrl, string authorizationCode)
        {
            var entity = "client_id=" + this.appId + "&client_secret=" + this.appSecret + "&code=" + authorizationCode +
            "&redirect_uri=" + HttpUtility.UrlEncode(returnUrl.AbsoluteUri);

            var tokenRequest = WebRequest.Create(TokenEndpoint);
            tokenRequest.ContentType = "application/x-www-form-urlencoded";
            tokenRequest.ContentLength = entity.Length;
            tokenRequest.Method = "POST";

            using (Stream requestStream = tokenRequest.GetRequestStream())
            {
                var writer = new StreamWriter(requestStream);
                writer.Write(entity);
                writer.Flush();
            }

            HttpWebResponse tokenResponse = (HttpWebResponse) tokenRequest.GetResponse();
            if (tokenResponse.StatusCode == HttpStatusCode.OK)
            {
                using (Stream responseStream = tokenResponse.GetResponseStream())
                {
                    //SE gives us the response as a string. Not an argument appended to the callback but a string in the body of a page.
                    // It looks like this: access_token=fdagfdsf4&expires=8600
                    var response = tokenResponse.GetResponseStream();
                    StreamReader reader = new StreamReader(responseStream);
                    var responseString = reader.ReadToEnd();
                    var tokenSection = responseString.Split('&')[0];
                    return tokenSection.Split('=')[1];
                }
            }
            return null;
        }
        #endregion
    }

One last thing! In your /App_Start/AuthConfig.cs file, you will need to add this line somewhere in the RegisterAuth() method: OAuthWebSecurity.RegisterClient(new SeoAuthClient("YourAppId", "YourAppSecret", "YourKey"), "Stack Overflow", null);

If anything needs clarification, please contact me or leave a comment below.

3 Responses to “Using DotNetOpenAuth to Authenticate Against StackOverflow in an MVC 4 Application”

  1. Awesome article.

  2. André Silva Says:

    Excelent work. Really hope I use that any time soon.

  3. Thank you very much. Based on your post and the LinkedIn Client I was able to build one for Intuit Anywhere.

    BTW, when building classes based off of JSON check out Sublime Text 2. It has a multi-line editor with a multi-line clipboard support; copy-paste is in the context of the row.

Leave a Reply

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

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>