Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single page applications social logins e.g facebook, google etc #479

Closed
asadsahi opened this issue Oct 5, 2017 · 26 comments
Closed

Single page applications social logins e.g facebook, google etc #479

asadsahi opened this issue Oct 5, 2017 · 26 comments
Labels

Comments

@asadsahi
Copy link

asadsahi commented Oct 5, 2017

In Visual studio MVC templates there are examples of social logins. Methods like ExternalLoginCallback (get) and ExternalLoginCallback (post) to register the account with that social login. This relies on Cookies.

In SPA based scenarios how we can achieve social logins relying on typical tokens. If we keep same mechanism to redirect social sites to ExternalLoginCallback (get) callback and load screen to register then how and at what point do we return tokens?

I have an open source project here which work with local logins (email password based logins) but social logins is one missing piece from it. Appreciate any helpful pointers. Thanks

@dfederm
Copy link

dfederm commented Oct 5, 2017

I solved this problem for my SPA, but I tend to believe I reinvented the wheel a bit as there is a bunch of code I had to write.

In theory, the existing ExternalLogin stuff would just work but issue a token instead of a cookie. I couldn't figure out how to do that though.

The gist for my solution is just to get an external token in the "normal" way on the client (ideally an id token, but in some cases an access token was all they offered) using the JS libs of that 3rd party. Then, I implemented the assertion grant flow on the server which took the assertion (id or access token), validated it using whatever mechanism the external provider provides (usually an API to validate the id token or for Facebook I had to just use the access token myself assuming that if it's usable it's valid). Once validated, I map the external identity to my site's identity and issue a token.

One indirect benefit is that since the client gets the external provider's token, there aren't any secrets involved which simplifies deployment a bit.

Here's my code:
dfederm/ClickerHeroesTracker@3cc781e

@asadsahi
Copy link
Author

asadsahi commented Nov 7, 2017

@PinpointTownes I can't find an easy way to generate authentication ticket.

What I am trying to do is to authenticate user like we generate ticket during normal login , but after social login authentication callback. I want to generate similar ticket and send to client (e.g Angular in this case). In the login example we have passed in OpenIdConnectRequest which isn't available during social login redirect. I am wonding if there is a simple api available in openiddict-core to generate same authentication ticket by just passing in User Principal.

Thanks

@kevinchalet
Copy link
Member

What I am trying to do is to authenticate user like we generate ticket during normal login , but after social login authentication callback.

Assuming external authentication is handled using the regular ASP.NET Core Facebook/Google/Twitter/whatver middleware, you need to implement an interactive flow like implicit: you won't be able to use the token endpoint - which is an API endpoint - will have to use the authorization endpoint: https://github.com/openiddict/openiddict-samples/blob/dev/samples/ImplicitFlow/AuthorizationServer/Controllers/AuthorizationController.cs

@asadsahi
Copy link
Author

asadsahi commented Nov 7, 2017

@PinpointTownes Yes external authentication is handle by asp.net core. This is exact implementation that comes out of the box in asp.net core mvc templates and I am trying to use in my SPA template which is a modification of same template. As in this source:

  1. Click on Google etc button
  2. Send to google for authentication ([HttpGet("ExternalLogin")]) line 117
  3. Callback hit after authentication ([HttpGet("ExternalLoginCallback")]) line 127
  4. Direct user to create a local account (e.g in template create account with an email)
    Note: here we are using cookies as normal to store google user info to receive once user creates account to link this information
  5. Create account [HttpPost("ExternalLoginCreateAccount")] line 169
    In mvc default implementation after account is created, user is signed in using cookie authenitcaiton (at line 187), code
                    await _signInManager.SignInAsync(user, isPersistent: false);

But I thought instead of signing in here which does cookies authentication I will create authentication ticket and send back to client.

So I have two questions:

  1. Is this correct approach?
  2. How to achieve this as per implicit flow you mentioned above?

Thanks for your help as always.

@kevinchalet
Copy link
Member

Is this correct approach?

No. OpenIddict won't allow you to return an OIDC response from an arbitrary endpoint - i.e your custom ExternalLoginCreateAccount endpoint - and will throw an InvalidOperationException saying you're doing crazy things.

How to achieve this as per implicit flow you mentioned above?

https://stackoverflow.com/questions/46475489/asp-net-core-signinprincipal-properties-authenticationscheme-is-throwing-an

@asadsahi
Copy link
Author

asadsahi commented Nov 7, 2017

@PinpointTownes following your answer on stackoverflow I have added Implicit flow in the application and corresponding authorisation endpoint. LocalRedirect is failing with following error:

error:invalid_request
error_description:The 'redirect_uri' parameter must be a valid absolute URL.

Here is my code with left hand side account creation method which localredirect to connect/authorise endpoint and on the right hand side its that endpoing:

image

Not sure if this is the right way. But I am passing in OIDC parameters in query string.

Source code until this point is in this branch.

Please note that this is a single project which self-hosts openidconnect server/aspnetcore api and angular application.

@kevinchalet
Copy link
Member

I strongly encourage you to use the AccountController that ships with the VS templates without trying to modify that part (you really don't need to).

I think you're trying to do things in the wrong order. As explained in the SO post, the client application is supposed to redirect the user to the authorization endpoint, that will itself redirect the user to the external login page. Just call return LocalRedirect(returnUrl) instead of trying to create an OIDC request in your controller.

@asadsahi
Copy link
Author

asadsahi commented Nov 7, 2017

@PinpointTownes return url isn't the issue here. Since this is the Angular client application I have different client side routes after external login verification, therefore I don't populate returnUrl when external login even starts, therefore that is empty anyway.

Challenge still exist in somehow making angular client to receive same ticket that we receive during password flow. I haven't seen any implementation of facebook/google authentication which uses purely token based authentication.

Second challenge I am still trying to understand is that if an external user already has created previously how do we auto login them without using cookies?

The only place this application is using cookies when we have to temporarily persist external login information until user creates a unique account with an email at that point VS templates sign in with cookies and this is exactly where I want to generate ticket instead, with async callbacks. This is slightly different way to authenticate comparing to actual VS templates.

Apologies if I am unable to explain it well, but if you run the project itself, you'll perhaps understand what I am trying to do.

@kevinchalet
Copy link
Member

I took a look at your branch, but without seeing the JS part, it's hard to see what you're trying to do 🤔

That said, the root cause of the error message you're seeing is simple: you've added simple quotes everywhere in the URL, which produces invalid parameters:

~/connect/authorize?client_id='aspnetcorespa'&response_type='id_token token'&redirect_uri='http://localhost:5000/login'&scope='openid email roles profile'

->

~/connect/authorize?client_id=aspnetcorespa&response_type=id_token%20token&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Flogin&scope=openid%20email%20roles%20profile

@asadsahi
Copy link
Author

asadsahi commented Nov 7, 2017

what is nonce parameter?

error:invalid_request
error_description:The mandatory 'nonce' parameter is missing.

@kevinchalet
Copy link
Member

nonce
REQUIRED. String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST be present in the nonce values used to prevent attackers from guessing values. For implementation notes, see Section 15.5.2.

http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest

@asadsahi
Copy link
Author

asadsahi commented Nov 8, 2017

@PinpointTownes thanks.

further progress :)

I have managed to hit the connect/authorize endpoint, but it is getting IsAuthenticated as false.

Even after external login success and the account already exist in database and ExternalLoginSignInAsync succeed and redirect to connect/authorize:

 var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);
            if (result.Succeeded)
            {
                _logger.LogInformation(5, "User logged in with {Name} provider.", info.LoginProvider);

                // var ticket = await AppUtils.CreateTicketAsync(_signInManager, _identityOptions);
                return LocalRedirect("~/connect/authorize?client_id=aspnetcorespa&response_type=id_token%20token&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Flogin&scope=openid%20email%20roles%20profile&nonce=test");

                // return Render(ExternalLoginStatus.Ok); // Everything Ok, login user
            }

Getting:
image

I suspect it isn't persisting the user information during local redirect. Any idea?

@kevinchalet
Copy link
Member

You'll probably have to use var user = await HttpContext.AuthenticateAsync("The scheme corresponding to your intermediate authentication cookie") instead of the ControllerBase.User property, that contains the default scheme identity.

@asadsahi
Copy link
Author

asadsahi commented Nov 8, 2017

Even I have only jwt as registered authentication schemes:

   services.AddAuthentication(options =>
            {
                // This will override default cookies authentication scheme
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultForbidScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            })

Not sure why this is null as well:

            var u = await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);

Confusing thing is that there is no cookies scheme registered throughout system, then how come _signInManager.GetExternalLoginInfoAsync() pulls the information of user without issue:

   [HttpPost("ExternalLoginCreateAccount")]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> ExternalLoginCreateAccount([FromBody]ExternalLoginConfirmationViewModel model, string returnUrl = null)
        {
            // Get the information about the user from the external login provider
            **var info = await _signInManager.GetExternalLoginInfoAsync();**
            if (info == null)
            {
                return BadRequest("External login information cannot be accessed, try again.");
            }

Any other way you get user info you recon during localredirect?

Thanks

@asadsahi
Copy link
Author

asadsahi commented Nov 8, 2017

@PinpointTownes with local redirect, Request Object has the External login cookie, therefore:

            var info = await _signInManager.GetExternalLoginInfoAsync();

I am able to get info for external login in connect/authorize endpoint. But since I haven't added cookies as authentication scheme some of the user authentication apis e.g

_signInManager.ExternalLoginSignInAsync

don't persist User on local redirect.

Perhaps I have to pull the user again using _signInManager.ExternalLoginSignInAsync in connect/authorize.

@asadsahi
Copy link
Author

asadsahi commented Nov 8, 2017

@PinpointTownes ignore above comments, on localredirect all information is lost, nothing is available in connect/authorize.

Before Redirect:
image

After redirect:
image

Not sure what pattern is suggested to persist information across redirects without using cookies.

@asadsahi
Copy link
Author

asadsahi commented Nov 8, 2017

@PinpointTownes I think what you said was absolutely right that I should just use return url to localredirect and let ImplicitFlow endpoint handle the signin logic. I will change that and show you once done.

As a side note, do you know if it is possible to open external login page as a popup instead? Currently

            return Challenge(properties, provider);

this call just go to external login page within same window.

@asadsahi
Copy link
Author

@PinpointTownes sorry to bother you again. Finally I have got something working purely with JWT.

Main files for these changes are AuthorizationController & AccountController. Would you mind reviewing these changes to point out if anything needs improving.

Really appreciate your help in pointing out in the right direction.

Thanks once again.

@kevinchalet
Copy link
Member

@asadsahi I'll take a closer look today but it's still not clear for me why you're building the authorization request payload (the /connect/authorize URL) server-side. It should definitely be done by the JS client. I think you didn't solve your "reversed logic" issue.

@asadsahi
Copy link
Author

asadsahi commented Nov 11, 2017

@PinpointTownes
As per asp.net mvc core templates after social logins verification user is requested to create a local account to complete the authentication (which is cookies based).

My application is token based. Therefore, mapping the same idea when user is redirected back to our site from social login page, there are two possibilities:

  1. User has already created a local account previously
    In that case how to generate ticket and render the page with user pre-authenticated. SignIn action is the only action that I could see where it renders the angular single page with ticket passed in as hash part of url which I am reading and setting in local storage to make user pre authenticated unless you suggest some other way to generate ticket.

  2. User needs to create local account for the first time

    In this case angular client app could possibly create an implicit flow request (as you suggested), which I am doing in ExternalLoginCreateAccount action which does localredirect implicit flow request. That means connect/authorize enpoint works in both 1 & 2 scenarios. Now if you suggest to do this through angular implicit flow post request, I also need to pass in email address (or any other new account creation details)??? Correct me if I am wrong.

I might be doing something totally wrong, but angular spa and SignIn being the only way to generate ticket, I can't see any other alternative.

A working demo is here.

http://aspnetcorespa.azurewebsites.net

@kevinchalet
Copy link
Member

kevinchalet commented Nov 11, 2017

Here's the flow you're currently using:

image

What I'm suggesting is to replace the first step by a redirection to the authorization endpoint directly made by the JS client (so that the /connect/authorize URL is generated by the JS app, not by your MVC controllers).

If you want to redirect the user agent to the "selected external provider", you can easily tweak the authorization endpoint action to accept a custom "provider" parameter and redirect the user agent to the ExternalLogin action. Here's an example:

[HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request)
{
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        // If an identity provider was explicitly specified, redirect
        // the user agent to the AccountController.ExternalLogin action.
        var provider = (string) request["provider"];
        if (!string.IsNullOrEmpty(provider))
        {
            return RedirectToAction("ExternalLogin", "Account", new
            {
                provider = provider,
                returnUrl = Request.PathBase + Request.Path + Request.QueryString
            });
        }

        return Render(ExternalLoginStatus.Error);
     }

    // ...
}

If you want to avoid the extra hop, you can merge ExternalLogin's logic into Authorize. E.g:

[HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request)
{
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        // If an identity provider was explicitly specified, return a challenge
        // to redirect the user to the issuer's remote authorization endpoint.
        var provider = (string) request["provider"];
        if (!string.IsNullOrEmpty(provider))
        {
            // Request a redirect to the external login provider.
            var returnUrl = Request.PathBase + Request.Path + Request.QueryString;
            var redirectUrl = Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl });
            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            return Challenge(properties, provider);
        }

        return Render(ExternalLoginStatus.Error);
    }

    // ...
}

It's important to note that your custom flow has a major vulnerability: it's prone to XSRF attacks as nothing prevents me from redirecting a victim to http://aspnetcorespa.azurewebsites.net/#access_token=my own token, which will result in the victim being logged in under my own account.

Consider using an OIDC JS library that takes care of everything for you. E.g https://github.com/manfredsteyer/angular-oauth2-oidc

@asadsahi
Copy link
Author

asadsahi commented Nov 12, 2017

@PinpointTownes Thanks for explaining once again. :) Hopefully we'll get there.

I have changed the whole flow as you suggested. I have also incorportaed angular-oauth2-oidc for the whole password & implicit flow. Implicit flow branch has these changes.

So there aren't any ExternalLogin,ExternalLoginCallback,CreateExternalLoginAccount endpoints anymore. All this work is done in connect/authorize endpoint now. Implicit request is created from js client now for both external login account which don't have local accounts and logging in of external accounts which have accounts already. Both requests are created by angular-oauth2-oidc apis.

In short, both flows are triggered using implicit request from js client.

On your point regarding XSRF, thanks for pointing out. Just to mention that URL is still save even requests are handled by agular-oauth2-oidc client. Is this fine?

Note: I haven't updated the demo site yet as there is a problem with angular-oauth2-oidc i.e password flow is broken now. Even I receive id_token from password flow, Library isn't setting it in session storage. It sets id_token received from implicit flow however. Have raided an issue here.

@asadsahi
Copy link
Author

asadsahi commented Nov 13, 2017

@PinpointTownes do you think anything around openidconnect package for this issue? Even I haven't overriden any option for password flow. id_token isn't being set by angular-oauth2-oidc for some reason. It sets the access_token fine.

@kevinchalet
Copy link
Member

Is this fine?

As long as the state is unpredictable and correctly checked, that's fine.

@PinpointTownes do you think anything around openidconnect package for this issue?

In the password flow (which is an OAuth2 flow that was not included in OIDC), there was no identity token concept. For convenience and consistency with the other flows, OpenIddict allows returning an identity token in all the flows, including password, client credentials and custom flows (openiddict/openiddict-samples#40 (comment)).

Since it's essentially a custom parameter, I'm not surprised it's not natively supported by third-party libs.

@asadsahi
Copy link
Author

asadsahi commented Nov 13, 2017

@PinpointTownes Thanks for explaining.

I don't understand how can I validate the state is unpredictable? Even browser url has the ticket in it on response, can't think of anything else we could do to avoid that. Now it is handled by js lib, does that make it unpredictable?

id_token is quite handy in spa's to display user info (as you have explained in thread you sent). For now I am setting id_token explicitly in password flow. But I am wondering why it stores in implict flow but not password flow. (can that be a bug on the grounds of consistence rather expecting this to be standard in js lib?)

@xperiandri
Copy link
Contributor

I thought about this scenarios and came to a conclusion that the simplest way is to open a new window and start implicit flow there. When redirect URI matches your specific URI you get tokens from that window and close it.
This way you don't close SPA and preserve security.

Using this suggestion you will have an instant redirect to an external provider.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants