Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MVC4's DotNetOpenAuth TwitterClient sample does not respect prior login

If I create an ASP.NET MVC 4 Web Application using the Internet Application template, it pre-installs all the components and configuration necessary to implement authentication using a range of OAuth and OpenID providers. Just adding my Twitter consumer key and secret to AuthConfig.cs activates authentication via Twitter.

However, it doesn't seem to work as I would expect.

If I attempt to authenticate using Twitter, it invariably displays a Twitter sign-on page, regardless of whether I am already signed on to Twitter. It also logs me out of Twitter, so that I am forced to re-authenticate on my next browser visit to Twitter.

Is this a bug, or is some additional configuration necessary to transform this into the more usual seamless workflow (which is working correctly for other providers like Google)?

Thanks, in advance.

Tim

like image 830
Tim Coulter Avatar asked Feb 18 '23 18:02

Tim Coulter


1 Answers

In case anyone else comes up against this issue, I'll present here what I have discovered (together with a rather ugly workaround).

Using Fiddler to examine the HTTP traffic between DotNetOpenAuth and Twitter, it is clear that the authentication request contains the force_login=false querystring parameter, which suggests that DNOA is working correctly. However, if I use Fiddler's scripting capability to modify the outbound request and remove the force_login parameter altogether, everything starts working correctly. I am guessing that Twitter's implementation is at fault here, by treating the presence of any force_login parameter as being equivalent to force_login=true.

Since I don't imagine it will be possible to get Twitter to modify the behavior of their API, I have investigated whether there is a more accessible solution.

Looking at the DNOA code, I see that the force_login=false parameter is unconditionally added to the HTTP request by the DotNetOpenAuthWebConsumer.RequestAuthentication() method (and subsequently modified to true when required).

So, the ideal solution would be for DNOA to offer finer-grained control over its authentication request parameters and for TwitterClient to explicitly remove the force_login=false parameter. Unfortunately, the current DNOA codebase doesn't directly support this, but it is possible to achieve the same effect by creating two custom classes.

The first is a custom implementation of IOAuthWebWorker which is a direct copy of the original DotNetOpenAuthWebConsumer class, apart from a single-line change that initializes the redirect parameter dictionary as an empty dictionary:

using System;
using System.Collections.Generic;
using System.Net;
using DotNetOpenAuth.AspNet.Clients;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OAuth;
using DotNetOpenAuth.OAuth.ChannelElements;
using DotNetOpenAuth.OAuth.Messages;

namespace CustomDotNetOpenAuth
{
    public class CustomDotNetOpenAuthWebConsumer : IOAuthWebWorker, IDisposable
    {
        private readonly WebConsumer _webConsumer;

        public CustomDotNetOpenAuthWebConsumer(ServiceProviderDescription serviceDescription, IConsumerTokenManager tokenManager)
        {
            if (serviceDescription == null) throw new ArgumentNullException("serviceDescription");
            if (tokenManager == null) throw new ArgumentNullException("tokenManager");

            _webConsumer = new WebConsumer(serviceDescription, tokenManager);
        }

        public HttpWebRequest PrepareAuthorizedRequest(MessageReceivingEndpoint profileEndpoint, string accessToken)
        {
            return _webConsumer.PrepareAuthorizedRequest(profileEndpoint, accessToken);
        }

        public AuthorizedTokenResponse ProcessUserAuthorization()
        {
            return _webConsumer.ProcessUserAuthorization();
        }

        public void RequestAuthentication(Uri callback)
        {
            var redirectParameters = new Dictionary<string, string>();
            var request = _webConsumer.PrepareRequestUserAuthorization(callback, null, redirectParameters);

            _webConsumer.Channel.PrepareResponse(request).Send();
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                _webConsumer.Dispose();
            }
        }
    }
}

The other requirement is a custom OAuthClient class, based on the original TwitterClient class. Note that this requires a little more code than the original TwitterClient class, as it also needs to replicate a couple of methods that are internal to the DNOA base class or other utility classes:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using DotNetOpenAuth.AspNet;
using DotNetOpenAuth.AspNet.Clients;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OAuth;
using DotNetOpenAuth.OAuth.ChannelElements;
using DotNetOpenAuth.OAuth.Messages;

namespace CustomDotNetOpenAuth
{
    public class CustomTwitterClient : OAuthClient
    {
        private static readonly string[] UriRfc3986CharsToEscape = new[] { "!", "*", "'", "(", ")" };

        public static readonly ServiceProviderDescription TwitterServiceDescription = new ServiceProviderDescription
        {
            RequestTokenEndpoint = new MessageReceivingEndpoint("https://api.twitter.com/oauth/request_token", HttpDeliveryMethods.GetRequest | HttpDeliveryMethods.AuthorizationHeaderRequest),
            UserAuthorizationEndpoint = new MessageReceivingEndpoint("https://api.twitter.com/oauth/authenticate", HttpDeliveryMethods.GetRequest | HttpDeliveryMethods.AuthorizationHeaderRequest),
            AccessTokenEndpoint = new MessageReceivingEndpoint("https://api.twitter.com/oauth/access_token", HttpDeliveryMethods.GetRequest | HttpDeliveryMethods.AuthorizationHeaderRequest),
            TamperProtectionElements = new ITamperProtectionChannelBindingElement[] { new HmacSha1SigningBindingElement() },
        };

        public CustomTwitterClient(string consumerKey, string consumerSecret)
            : this(consumerKey, consumerSecret, new AuthenticationOnlyCookieOAuthTokenManager())
        {
        }

        public CustomTwitterClient(string consumerKey, string consumerSecret, IOAuthTokenManager tokenManager)
            : base("twitter", new CustomDotNetOpenAuthWebConsumer(TwitterServiceDescription, new SimpleConsumerTokenManager(consumerKey, consumerSecret, tokenManager)))
        {
        }

        protected override AuthenticationResult VerifyAuthenticationCore(AuthorizedTokenResponse response)
        {
            var accessToken = response.AccessToken;
            var userId = response.ExtraData["user_id"];
            var userName = response.ExtraData["screen_name"];

            var profileRequestUrl = new Uri("https://api.twitter.com/1/users/show.xml?user_id=" + EscapeUriDataStringRfc3986(userId));
            var profileEndpoint = new MessageReceivingEndpoint(profileRequestUrl, HttpDeliveryMethods.GetRequest);
            var request = WebWorker.PrepareAuthorizedRequest(profileEndpoint, accessToken);

            var extraData = new Dictionary<string, string> { { "accesstoken", accessToken } };

            try
            {
                using (var profileResponse = request.GetResponse())
                {
                    using (var responseStream = profileResponse.GetResponseStream())
                    {
                        var document = xLoadXDocumentFromStream(responseStream);

                        AddDataIfNotEmpty(extraData, document, "name");
                        AddDataIfNotEmpty(extraData, document, "location");
                        AddDataIfNotEmpty(extraData, document, "description");
                        AddDataIfNotEmpty(extraData, document, "url");
                    }
                }
            }
            catch
            {
                // At this point, the authentication is already successful. Here we are just trying to get additional data if we can. If it fails, no problem.
            }

            return new AuthenticationResult(true, ProviderName, userId, userName, extraData);
        }

        private static XDocument xLoadXDocumentFromStream(Stream stream)
        {
            const int maxChars = 0x10000; // 64k

            var settings = new XmlReaderSettings
                {
                MaxCharactersInDocument = maxChars
            };

            return XDocument.Load(XmlReader.Create(stream, settings));
        }

        private static void AddDataIfNotEmpty(Dictionary<string, string> dictionary, XDocument document, string elementName)
        {
            var element = document.Root.Element(elementName);

            if (element != null)
            {
                AddItemIfNotEmpty(dictionary, elementName, element.Value);
            }
        }

        private static void AddItemIfNotEmpty(IDictionary<string, string> dictionary, string key, string value)
        {
            if (key == null)
            {
                throw new ArgumentNullException("key");
            }

            if (!string.IsNullOrEmpty(value))
            {
                dictionary[key] = value;
            }
        }

        private static string EscapeUriDataStringRfc3986(string value)
        {
            var escaped = new StringBuilder(Uri.EscapeDataString(value));

            for (var i = 0; i < UriRfc3986CharsToEscape.Length; i++)
            {
                escaped.Replace(UriRfc3986CharsToEscape[i], Uri.HexEscape(UriRfc3986CharsToEscape[i][0]));
            }

            return escaped.ToString();
        }
    }
}

Having created these two custom classes, implementation simply entails registering an instance of the new CustomTwitterClient class in the MVC4 AuthConfig.cs file:

OAuthWebSecurity.RegisterClient(new CustomTwitterClient("myTwitterApiKey", "myTwitterApiSecret"));
like image 129
Tim Coulter Avatar answered Feb 22 '23 00:02

Tim Coulter