13

Setup: New MVC5 Project with just Web API. Added Facebook AppId and Secret.
I can get Token for my Web API from Token endpoint by passing in UserName and Password. Then use that token for further calls.

BUT I want to register new users with the help of Facebook SDK in iOS app. I am using Facebook SDK to get Access Token. (Assume at this point, I have an Access Token).

Next thing I know is to call api/Account/RegisterExternal endpoint by passing this token in Authorization header with Bearer [Access Token] but this result in 500 server error.

I guess I know the reason, Cookie is missing. I made the same call with a cookie from Fidler and it worked. (Cookie is received by going to URL provided by ExternalLogins endpoint). As cookie is missing await Authentication.GetExternalLoginInfoAsync(); inside the RegisterExternal action returns null.

// POST api/Account/RegisterExternal
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("RegisterExternal")]
public async Task<IHttpActionResult> RegisterExternal(RegisterExternalBindingModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var info = await Authentication.GetExternalLoginInfoAsync();
    if (info == null)
    {
        return InternalServerError();
    }

    var user = new ApplicationUser() { UserName = model.Email, Email = model.Email };

    IdentityResult result = await UserManager.CreateAsync(user);
    if (!result.Succeeded)
    {
        return GetErrorResult(result);
    }

    result = await UserManager.AddLoginAsync(user.Id, info.Login);
    if (!result.Succeeded)
    {
        return GetErrorResult(result);
    }
    return Ok();
}

I don't want to make 3 calls to my Web API to ask for external logins and then goto that URL and authenticate in a Web Browser for Facebook access token and then call the RegisterExternal endpoint with that access token and Cookie that I need to collect between these calls.

As I said I didn't change anything in template except the Facebook Ids. Still the code is as below.

public partial class Startup
{
    public static OAuthAuthorizationServerOptions OAuthOptions { get; private set; }

    public static string PublicClientId { get; private set; }

    // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864
    public void ConfigureAuth(IAppBuilder app)
    {
        // Configure the db context and user manager to use a single instance per request
        app.CreatePerOwinContext(ApplicationDbContext.Create);
        app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);

        // Enable the application to use a cookie to store information for the signed in user
        // and to use a cookie to temporarily store information about a user logging in with a third party login provider
        app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

        // Configure the application for OAuth based flow
        PublicClientId = "self";
        OAuthOptions = new OAuthAuthorizationServerOptions
        {
            TokenEndpointPath = new PathString("/Token"),
            Provider = new ApplicationOAuthProvider(PublicClientId),
            AuthorizeEndpointPath = new PathString("/api/Account/ExternalLogin"),
            AccessTokenExpireTimeSpan = TimeSpan.FromDays(14),
            AllowInsecureHttp = true
        };

        // Enable the application to use bearer tokens to authenticate users
        app.UseOAuthBearerTokens(OAuthOptions);

        app.UseFacebookAuthentication(
            appId: "xxxxxxxxxxxxxxx",
            appSecret: "xxxxxxxxxxxxxxxxxxxxxxxx");
    }
}

as far as I know, Web API doesn't need Cookie and that appears true when I have Local Token from Token endpoint but why does it require Cookie in the first place when doing ExternalRegister WebApiConfig class looks like this and shouldn't config.SuppressDefaultHostAuthentication(); avoid any Cookie needs

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        // Configure Web API to use only bearer token authentication.
        config.SuppressDefaultHostAuthentication();
        config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));

        // Web API routes
        config.MapHttpAttributeRoutes();

        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );
    }
}

I don't know if I am missing the point here.. My intentions are to not need to use web browser in a native iOS app for the token. That is Facebook SDK to get access token and using that call RegisterExternal to get the Local Token and create that users Identity.

I did my homework and I am stuck on this thought. Thoughts appreciated!

Hakan Fıstık
  • 16,800
  • 14
  • 110
  • 131
parveen
  • 1,939
  • 1
  • 17
  • 33
  • any solution found yet? i would be very interested because i have the same problem. – Freddy Jan 27 '15 at 15:38
  • @Freddy yea, I ended up putting different pieces together to create my own solution, works great. Will be posting it. Currently out of town, ping me after 4 days if I don't post it. – parveen Jan 30 '15 at 17:28
  • that would be really generous of you. I am currently at work creating my own solution, too... – Freddy Jan 31 '15 at 18:37
  • @Freddy find the answer below :) I hope it helps. – parveen Feb 03 '15 at 12:21

2 Answers2

18

I was mistaken that it accepts the Social Token with cookie! It doesn't accept any External Token directly.

The thing is.. MVC 5 is taking care of everything for us, i.e. collecting token from Social Medias and validating/processing it. After that it generates a local token.

The RegisterExternal method also requires cookies to be maintained, the solution does not.

I have written a blog post which will explain in detail. Added the straight forward answer below. I aimed to make it blend and feel integral part of Login/Signup flow of default MVC Web API to make sure its easy to understand.

After the below solution, Authorize attribute must be as below to work or you will get Unauthorized response.

[Authorize]
[HostAuthentication(Microsoft.AspNet.Identity.DefaultAuthenticationTypes.ExternalBearer)]
[HostAuthentication(Microsoft.AspNet.Identity.DefaultAuthenticationTypes.ApplicationCookie)]

Use ExternalBearer if you want to allow only Tokens to use API, use ApplicationCookie if you want to allow only Logged cookie to use API i.e. from a website. User both if you want to allow the API for both.

Add this action to AccountController.cs

// POST api/Account/RegisterExternalToken
[OverrideAuthentication]
[AllowAnonymous]
[Route("RegisterExternalToken")]
public async Task<IHttpActionResult> RegisterExternalToken(RegisterExternalTokenBindingModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    ExternalLoginData externalLogin = await ExternalLoginData.FromToken(model.Provider, model.Token);

    if (externalLogin == null)
    {
        return InternalServerError();
    }

    if (externalLogin.LoginProvider != model.Provider)
    {
        Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
        return InternalServerError();
    }

    ApplicationUser user = await UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider,
        externalLogin.ProviderKey));

    bool hasRegistered = user != null;
    ClaimsIdentity identity = null;
    IdentityResult result;

    if (hasRegistered)
    {
        identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
        IEnumerable<Claim> claims = externalLogin.GetClaims();
        identity.AddClaims(claims);
        Authentication.SignIn(identity);
    }
    else
    {
        user = new ApplicationUser() { Id = Guid.NewGuid().ToString(), UserName = model.Email, Email = model.Email };

        result = await UserManager.CreateAsync(user);
        if (!result.Succeeded)
        {
            return GetErrorResult(result);
        }

        var info = new ExternalLoginInfo()
        {
            DefaultUserName = model.Email,
            Login = new UserLoginInfo(model.Provider, externalLogin.ProviderKey)
        };

        result = await UserManager.AddLoginAsync(user.Id, info.Login);
        if (!result.Succeeded)
        {
            return GetErrorResult(result);
        }

        identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
        IEnumerable<Claim> claims = externalLogin.GetClaims();
        identity.AddClaims(claims);
        Authentication.SignIn(identity);
    }

    AuthenticationTicket ticket = new AuthenticationTicket(identity, new AuthenticationProperties());
    var currentUtc = new Microsoft.Owin.Infrastructure.SystemClock().UtcNow;
    ticket.Properties.IssuedUtc = currentUtc;
    ticket.Properties.ExpiresUtc = currentUtc.Add(TimeSpan.FromDays(365));
    var accessToken = Startup.OAuthOptions.AccessTokenFormat.Protect(ticket);
    Request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);

    // Create the response building a JSON object that mimics exactly the one issued by the default /Token endpoint
    JObject token = new JObject(
        new JProperty("userName", user.UserName),
        new JProperty("id", user.Id),
        new JProperty("access_token", accessToken),
        new JProperty("token_type", "bearer"),
        new JProperty("expires_in", TimeSpan.FromDays(365).TotalSeconds.ToString()),
        new JProperty(".issued", currentUtc.ToString("ddd, dd MMM yyyy HH':'mm':'ss 'GMT'")),
        new JProperty(".expires", currentUtc.Add(TimeSpan.FromDays(365)).ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'"))
    );
    return Ok(token);
}

Add this helper method to ExternalLoginData class in helper region in AccountController.cs

public static async Task<ExternalLoginData> FromToken(string provider, string accessToken)
{
    string verifyTokenEndPoint = "", verifyAppEndpoint = "";

    if (provider == "Facebook")
    {
        verifyTokenEndPoint = string.Format("https://graph.facebook.com/me?access_token={0}", accessToken);
        verifyAppEndpoint = string.Format("https://graph.facebook.com/app?access_token={0}", accessToken);
    }
    else if (provider == "Google")
    {
        return null; // not implemented yet
        //verifyTokenEndPoint = string.Format("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={0}", accessToken);
    }
    else
    {
        return null;
    }

    HttpClient client = new HttpClient();
    Uri uri = new Uri(verifyTokenEndPoint);
    HttpResponseMessage response = await client.GetAsync(uri);
    ClaimsIdentity identity = null;
    if (response.IsSuccessStatusCode)
    {
        string content = await response.Content.ReadAsStringAsync();
        dynamic iObj = (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(content);

        uri = new Uri(verifyAppEndpoint);
        response = await client.GetAsync(uri);
        content = await response.Content.ReadAsStringAsync();
        dynamic appObj = (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(content);

        identity = new ClaimsIdentity(OAuthDefaults.AuthenticationType);

        if (provider == "Facebook")
        {
            if (appObj["id"] != Startup.facebookAuthOptions.AppId)
            {
                return null;
            }

            identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, iObj["id"].ToString(), ClaimValueTypes.String, "Facebook", "Facebook"));

        }
        else if (provider == "Google")
        {
            //not implemented yet
        }
    }

    if (identity == null)
        return null;

    Claim providerKeyClaim = identity.FindFirst(ClaimTypes.NameIdentifier);

    if (providerKeyClaim == null || String.IsNullOrEmpty(providerKeyClaim.Issuer) || String.IsNullOrEmpty(providerKeyClaim.Value))
        return null;

    if (providerKeyClaim.Issuer == ClaimsIdentity.DefaultIssuer)
        return null;

    return new ExternalLoginData
    {
        LoginProvider = providerKeyClaim.Issuer,
        ProviderKey = providerKeyClaim.Value,
        UserName = identity.FindFirstValue(ClaimTypes.Name)
    };
}

and finally, the RegisterExternalTokenBindingModel being used by the action.

public class RegisterExternalTokenBindingModel
{
    [Required]
    [Display(Name = "Email")]
    public string Email { get; set; }

    [Required]
    [Display(Name = "Token")]
    public string Token { get; set; }

    [Required]
    [Display(Name = "Provider")]
    public string Provider { get; set; }
}

Yes, we pass the email along with Token details while registering, this will not cause you to change the code when using Twitter, as Twitter doesn't provide users email. We verify token comes from our app. Once email registered, hacked or somebody else's token cannot be used to change email or get a local token for that email as it will always return the local token for the actual user of the Social Token passed regardless of the email sent.

RegisterExternalToken endpoint works to get token in both ways i.e. register the user and send the Local token or if the user already registered then send the token.

Hakan Fıstık
  • 16,800
  • 14
  • 110
  • 131
parveen
  • 1,939
  • 1
  • 17
  • 33
  • Wow this is awsome - great work! Although I have already implemented my own solution by using 2 own endpoints (one for registration and one for login and putting the 3rd party id in my own user table) I will give it a try soon. As I have integrated twitter I probably will have to adapt your solution because twitter uses appkey, appsecret as well as usertoken and usertokensecret for verifying... what do you think? And another point is where do you configure the social appkeys and so on? In StartupClass with app.useFacebookAuth? – Freddy Feb 04 '15 at 16:40
  • And another point is where do you configure the social appkeys and so on? In StartupClass with app.useFacebookAuth or not at all? Am I correct that you only require social login and no app verification? – Freddy Feb 04 '15 at 16:49
  • The next problem is with twitter that you dont get an email adress from the social login. So you have to ask your user in an additional dialog on the client. – Freddy Feb 04 '15 at 16:51
  • Everything remains the same, Keys and secrets remain in StartupClass just like before. As we are passing Social Token, So we have already configured and taken care of Secrets and Keys natively on device. We have the token, right? We don't need to use Secrets to call api after that. So we get the token and direct make call to respective Social Media, if we get a valid revert the token is valid and we process and register the user else respond with error. – parveen Feb 04 '15 at 16:57
  • I haven't used Twitter, only facebook(thats what the client needed). Twitter must be having an iOS SDK, you configure everything there Secrets and keys and get the token from there without even calling the API of our service. Then pass that token to the action it will make a call to twitter basic endpoint for basic info. if response is valid process it or return the error. Add an else if case for Twitter in FromToken helper method – parveen Feb 04 '15 at 17:02
  • About the email address. Feel free to modify the `RegisterExternalTokenBindingModel` according to your needs. I prefer associating it with mail thats why I am using email there. You can use anything and even get the email from user and pass it. – parveen Feb 04 '15 at 17:03
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/70244/discussion-between-freddy-and-codetrixstudio). – Freddy Feb 04 '15 at 17:04
  • 1
    @Freddy your security concern has been resolved. Also improved the code. Let me know if you found anything else in the code. :) – parveen Feb 06 '15 at 06:33
  • I will double check - but I have my own solution already running. As soon as I have time I will beautify the code and post it here, too. Thank you for sharing your knowledge! If you have problems somewhere else just let me know I will try to help... – Freddy Feb 27 '15 at 14:43
  • New with OAuth, I tried same code which you have posted here, Working fine with mvc application, But when I am passing Token from android, It's not working, because, android app is using different App-key and secrete key, So if (appObj["id"] != Startup.facebookAuthOptions.AppId) is false as I have different key-id. Do I need to use same id and secrete key in all apps ? Or I need to change somewhere ? Please help. – Keval Patel Dec 01 '15 at 16:37
  • 1
    @KevalPatel You should be using same id everywhere for one app. Anyways, replace it with below and you should be good to go. `if (appObj["id"] != Startup.facebookAuthOptions.AppId || appObj["id"] != "your other id here") { return null; }` – parveen Dec 03 '15 at 11:48
  • 1
    Can you provide Us with class "ExternalLoginData" class? – ASalameh Jul 16 '17 at 15:15
  • Would this solution also work for my problem? https://stackoverflow.com/questions/48855562/enabling-social-logins-with-asp-net-web-api-2-0-and-ionic-cordova-app – Ciaran Gallagher Feb 27 '18 at 21:38
  • Your blog post article link is dead, do you have a new one? – Ciaran Gallagher Feb 28 '18 at 20:38
  • Do you have an implementation for Google? – Ciaran Gallagher Feb 28 '18 at 20:56
  • 1
    @CiaranGallagher It's back up, forgot to renew the domain, phew! was a close call. Yea this will work for your question, I will answer there. – parveen Mar 04 '18 at 12:04
  • @CodetrixStudio Really I want to thank you, but I have a small problem when I got the `access_token` from the `RegisterExternalToken` method, when I am trying to use it to call some other normal method in the API, it gives me 401 error, I just notice that the `access_token` which I get from this method is really shorter than `access_token` which the ordinary login method give me, is this has any relativity with this problem. – Hakan Fıstık Jun 04 '18 at 07:37
  • @HakamFostok can you make sure you are using `Authorization attributes` as mentioned in first snippet of this answer? – parveen Jun 06 '18 at 08:52
0

Before everything, this is NOT A FULL Answer, this is just a note or an addition for the answer to avoid some problems which could cost you handful of days (in my case 3 days)

The previous answer is the full answer it just lacks from one thing, which is the following:
if you specified a role for the Authorize attribute, for example [Authorize("UserRole")] , the previous setup will still give you 401 error because the solution does not set the RoleClaim

and to solve this problem you have to add this line of code to the RegisterExternalToken method

oAuthIdentity.AddClaim(new Claim(ClaimTypes.Role, "UserRole"));
Hakan Fıstık
  • 16,800
  • 14
  • 110
  • 131