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
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"));