ASP.NET Core Identity Series – External provider authentication & registration strategy – chsakell’s Blog

There is no doubt that external provider authentication is a must have feature in new modern applications and makes sense because users are able to easily register new accounts and also login using their social account credentials. The entire process is based on OAuth 2.0 flows which were presented in detail in the OAuth 2.0, OpenID Connect & IdentityServer blog post of the ASP.NET Core Identity Series. In case you haven’t read it, I totally recommend you to do so. Web applications redirect users to sign in to the selected external provider which in turn redirects back to a callback url providing basic profile information such as email address, full name and username. This info is used to register a new account or sign in the user if already registered. If I were to tell what is the biggest advantage or gain when using social login I would say one world, TRUST. A trust that is built on the very well formed OAuth 2.0 protocol and the fact that user accounts are already confirmed in the external providers.

Love all, trust a few. – William Shakespeare

This post is part of the ASP.NET Core Identity Series:

The post is divided in the following sections:

In the first section we are going to configure authentication with Google, Facebook, Twitter, Microsoft, GitHub, LinkedIn and DropBox. We could add more but as you will see later, the process is always the same with a few minor changes for some providers (e.g. Twitter). For each provider there will be a step by step guide with screenshots along with the required commands and code to setup in our web application.

The second section gets even more interesting. The first part of enabling social login is to configure the external providers in the application. The second part is how you actually use them. Let’s consider the scenario where you have created a user account using the normal registration process, meaning you provided your email address, a username and a password. At some point the web site allows you to sign in using your Facebook account but unfortunately your facebook account uses a different email address. Does this mean that signing in with Facebook should create a new account? Or should you have the option to associate your Facebook account with your already confirmed one? The second one sounds a lot better because you will continue to use the same account containing the same data as before (e.g. orders, emails, chat messages, etc..). In order to support this kind of functionality you need to create rules and flows and implementing these two is what the registration strategy is all about. And of course ASP.NET Core Identity supports by default user account association with external login providers.

Configuring external providers authentication in ASP.NET Core

The steps to enable authentication for an external provider are always the same:

  • Step 1: Create an application to the external provider
  • Step 2: Configure the callback url properly
  • Step 3: Register the authentication handler for the external provider in Startup/ConfigureServices

All external providers (Facebook, Google, GitHub, etc..) provide a developer(s) website that you can use it and leverage their APIs and services. Here are some developer websites.

First you navigate to the developer app URL and create a new application for the website you want to add external provider authentication. The required details are usually the name and the URL of your website. To enable authentication in your app you have to set the callback URL which points to a route in your website. This is not an actual route you have created in your web app but a route that the registered authentication handler listens to and handles the external authentication result properly. By default the callback URL an authentication handler listens to is usually signin-<provider-name>, for example the google authentication handler listens to signin-google. This means that if your website’s URL is https://mywebsite.com then the callback URL at Google would be https://mywebsite.com/signin-google. Of course callback URLs are totally configurable, you can add more than one or change them at any time you want. Enough with the theory, let’s see in action how to configure external providers authentication.

The source code for the series is available here. Each part has a related branch on the repository. To follow along with this part clone the repository and checkout the external-authentication branch as follow:

git clone https://github.com/chsakell/aspnet-core-identity.git
cd .aspnet-core-identity
git checkout external-authentication

Keep in mind that master branch has been updated with .NET Core 3 & Angular 8!

In this post we will be working with th AspNetCoreIdentity project in the solution and we will add external providers authentication for multiple providers.

Let me remind you that the app already implements many ASP.NET Core Identity related features and can be run with or without an SQL Server. You can find instructions to setup the project on the README file. More over the AspNetCoreIdentity web app project is configured to run at http://localhost:5000 and this is the base URL that we will be using when we configuring the OAuth callback URL for the external providers.

In case you don’t want to set authentication for all external providers, just read the instructions for those you are interested in

Configure Google Authentication

  • Navigate to the Integrate Google Sign-In page and click the CONFIGURE A PROJECT button. Here you will create a Google API Console project and client ID
  • Enter the name for your project and click Next. This name won’t be used when the app requests access to your Google account. Here’s an example.
  • Next enter the name that will be shown on the user consent screen. The consent screen is shown to the user the very first time the app requests access to your Google account.

    Click Next and proceed to setup the callback URL
  • Select Web server for our application environment and add http://localhost:5000/signin-google as an authorized redirect URI.
  • The following screen displays the Client ID and Client Secret generated for your new Console project.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Google project’s client Id and Secret.
    // Google
    
    //dotnet user-secrets set "Authentication:Google:ClientId" ""
    //dotnet user-secrets set "Authentication:Google:ClientSecret" ""
    
    if (configuration["Authentication:Google:ClientId"] != null)
    {
        services.AddAuthentication().AddGoogle(o =>
        {
            o.ClientId = configuration["Authentication:Google:ClientId"];
            o.ClientSecret = configuration["Authentication:Google:ClientSecret"];
        });
    }
    

    All you have to do is open the Package Manager Console, cd to the AspNetCoreIdentity folder and run the dotnet user-secrets set command using your google project’s credentials.

    dotnet user-secrets set "Authentication:Google:ClientId" "<your-client-id>"
    dotnet user-secrets set "Authentication:Google:ClientSecret" "<your-client-secret-id>"
    

  • One last thing you need to know is that you can always re-configure your console project’s settings in console.developers.google.com. When you do that make sure you select the correct project from the top list and select Credentials from the left menu.

    If you click OAuth client you will see the OAuth configuration setup for the project.

    It’s always good to set a logo for your app and this can be done through the OAuth consent screen

    Configure Facebook Authentication

  • Navigate to the Facebook apps page, click the Add a New App button and fill the Create a New App ID popup form. The display name will be the name of the app will appear in the consent page.
  • When the App ID is created you will be redirected to the app’s page. Click the plus icon next to the Products menu item on the bottom left
  • Locate the Facebook Login product and click Set Up
  • Click the Settings menu item under the Facebook Login list and add http://localhost:5000/signin-facebook as an authorized redirect URI. Don’t forget to save the changes.

    Lately Facebook returns a message that localhost redirects do not need to be added. If you get this message just proceed with the next step

  • Click the Basic menu item under the Settings to retrieve your app’s credentials, App ID and App Secret
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Facebook app’s app Id and secret.
     
    // Facebook
    
    // dotnet user-secrets set Authentication:Facebook:AppId ""
    // dotnet user-secrets set Authentication:Facebook:AppSecret ""
    
    if (configuration["Authentication:Facebook:AppId"] != null)
    {
        services.AddAuthentication().AddFacebook(facebookOptions =>
        {
            facebookOptions.AppId = configuration["Authentication:Facebook:AppId"];
            facebookOptions.AppSecret = configuration["Authentication:Facebook:AppSecret"];
        });
    }
    

    Open the Package Manager Console, cd to the AspNetCoreIdentity folder and run the dotnet user-secrets set command using your facebook app’s credentials.

    dotnet user-secrets set Authentication:Facebook:AppId "<your-app-id>"
    dotnet user-secrets set Authentication:Facebook:AppSecret "<your-app-secret>"
    
  • Configure Twitter Authentication

  • Navigate to the Twitter apps page, click the Create an App button and fill the App details form. You will find that some fields are required, such as the Website URL. In case you don’t have valid values just fill with a fake one. The most important thing is to add the http://localhost:5000/signin-twitter as an authorized callback URI.

  • Fill and consent to any developer related terms required by Twitter
  • In the app’s view click the Keys and tokens tab to find the Twitter credentials for your app
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Twitter’s Consumer API key and secret.
    // Twitter
    
    // dotnet user-secrets set Authentication:Twitter:ConsumerAPIKey ""
    // dotnet user-secrets set Authentication:Twitter:ConsumerAPISecret ""
    
    if (configuration["Authentication:Twitter:ConsumerAPIKey"] != null)
    {
        services.AddAuthentication().AddTwitter(twitterOptions =>
        {
            twitterOptions.ConsumerKey = configuration["Authentication:Twitter:ConsumerAPIKey"];
            twitterOptions.ConsumerSecret = configuration["Authentication:Twitter:ConsumerAPISecret"];
            twitterOptions.RetrieveUserDetails = true;
        });
    }
    

  • You might think you have finished setting up Twitter authentication but you haven’t. Our app needs to read the external provider logged in user’s email address and by default Twitter doesn’t give this permission. Here’s a sneak peek to a debugging session while logged in with Twitter (we will study the code in more detail in the registration strategy..)

    As you can see there’s no email claim to retrieve and proceed with the authentication process. What you need to do is go back to the Twitter app’s App details view and fill the Terms of Service URL and Privacy policy URL fields.

    Next click the Permissions tab and check the Request email address from users checkbox. You won’t be able to check it unless you fill the previous fields.

    After doing so, here’s how the debugging session looks like.
  • Configure Microsoft Account Authentication

  • Navigate to the Azure apps registration page, click the New registration button and fill the form as follow. You can give your own name but make sure you set the Redirect URI to http://localhost:5000/signin-microsoft.
  • Select the Certificates & secrets from the left menu and click New client secret

    Check never and click >Add.

    Copy and save the client secret created.
  • To view your Application client id select Overview from the left menu.
  • Now that you have your app’s credentials switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your Micorosoft app’s credentials.
    // Microsoft
    
    // dotnet user-secrets set Authentication:Microsoft:ClientId ""
    // dotnet user-secrets set Authentication:Microsoft:ClientSecret ""
    
    if (configuration["Authentication:Microsoft:ClientId"] != null)
    {
        services.AddAuthentication().AddMicrosoftAccount(microsoftOptions =>
        {
            microsoftOptions.ClientId = configuration["Authentication:Microsoft:ClientId"];
            microsoftOptions.ClientSecret = configuration["Authentication:Microsoft:ClientSecret"];
        });
    }
    

  • In case you want to add a logo for your app select Branding from the left menu and upload your logo.
  • Configure GitHub Authentication

  • Navigate to the GitHub developers page and select OAuth Apps from the left menu. Next click Register a new application if it’s the first time you create an app on GitHub or the New OAuth app button.
  • Fill the form and make sure to add http://localhost:5000/signin-github as an authorized callback URL.
  • When the app is created you can get its client id and client secret. You can also set an application logo.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your GitHub app’s credentials.
    // dotnet user-secrets set Authentication:GitHub:ClientId ""
    // dotnet user-secrets set Authentication:GitHub:ClientSecret ""
    
    if (configuration["Authentication:GitHub:ClientId"] != null)
    {
        services.AddAuthentication().AddGitHub(gitHubOptions =>
        {
            gitHubOptions.ClientId = configuration["Authentication:GitHub:ClientId"];
            gitHubOptions.ClientSecret = configuration["Authentication:GitHub:ClientSecret"];
        });
    }
    

  • Configure LinkedIn Authentication

  • Navigate to the LinkedIn developers page and click the Create app button.
  • Fill all the form fields and optionally set an app logo. For the Company field set any company you want since it’s not going to be validated unless you request to.
  • In the app’s page select the Auth tab and get its OAuth credentials.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your LinkedIn app’s credentials.
    // LinkedIn
    
    // dotnet user-secrets set Authentication:LinkedIn:ClientId ""
    // dotnet user-secrets set Authentication:LinkedIn:ClientSecret ""
    
    if (configuration["Authentication:LinkedIn:ClientId"] != null)
    {
        services.AddAuthentication().AddLinkedIn(linkedInOptions =>
        {
            linkedInOptions.ClientId = configuration["Authentication:LinkedIn:ClientId"];
            linkedInOptions.ClientSecret = configuration["Authentication:LinkedIn:ClientSecret"];
            //linkedInOptions.CallbackPath = "/signin-linkedin";
        });
    }
    

  • Configure DropBox Authentication

  • Navigate to the DropBox developers apps page and click the Create app button.
  • Select Dropbox API and check the App folder – access to a single folder created specifically for your app checkbox. Next name your app as you wish.

    When you create a new app, DropBox will also create a new folder for this app.
  • In the app’s Settings tab add http://localhost:5000/signin-dropbox as an authorized redirect URI. Notice that the Settings view also contains the App key and App secret.
  • Switch to AspNetCoreIdentity project and search for the ExternalProvidersRegistrations class file. You will find a section to setup your DropBox app’s credentials.
    // DropBox
    
    // dotnet user-secrets set Authentication:DropBox:ClientKey ""
    // dotnet user-secrets set Authentication:DropBox:ClientSecret ""
    
    if (configuration["Authentication:DropBox:ClientKey"] != null)
    {
        services.AddAuthentication().AddDropbox(dropBoxOptions =>
        {
            dropBoxOptions.ClientId = configuration["Authentication:DropBox:ClientKey"];
            dropBoxOptions.ClientSecret = configuration["Authentication:DropBox:ClientSecret"];
            //dropBoxOptions.CallbackPath = "/signin-dropbox";
        });
    }
    

  • The Branding tab can be used to set your application logo. It seems that you can set a logo that already exists on your DropBox account.
  • Now that we have set authentication for all these providers let’s see how the consent page looks like for some of them:

    Facebook consent page

    Twitter consent page

    Microsoft consent page

    GitHub consent page

    LinkedIn consent page

    Dropbox consent page

    Any time you want to remove access given to an app, sign in to the specific provider and find the page for your account permissions. For example for Google permissions you can navigate to myaccount.google.com/permissions.

    External providers registration strategy

    After adding external providers authentication to your web application you will soon realize that there will be cases where you need to make a decision. The simplest scenario is where you have already registered an account through the normal process by providing your email, a username and a password. After using your credentials for a long time to authenticate, someday you decide that you want to sign in through your Facebook account. The thing is that your Facebook account is registered with a different email address which brings us to the million-dollar question: Is the app going to create a new account for that email address or is it going to provide the option to associate the Facebook address to an existing account? And if it’s the latter what is the right process to do so? In this post and the associated source code we will provide both the options, allowing the user to decide either to create a new account or associate an existing one. Before start exploring the source code we need to write down the rules and flows this strategy follows.

  • Rule #1: Users can sign in only if their email is confirmed. Normally websites let you sign in even if you haven’t confirmed your email address but prevent you from using their services (such as placing orders for example) till you do so. Our app isn’t that complex so we can stick with this rule for simplicity reasons
  • Rule #2: Users authenticated by an external provider are considered trusted. This means that if a user tries to register a new account through an external provider and selects the option to register a new account using the email address used in the provider, then the user won’t have to confirm the email address because it’s already confirmed by the provider
  • Rule #3: Users authenticated by an external provider that select the option to associate the email address used in the provider with an existing account registered with a different email address, will have to confirm the external provider association through the existing account’s email address
  • Rule #4: An association can only happen with already confirmed accounts
  • Rule #5: Users authenticated by an external provider but have an existing account with the same email address that hasn’t been confirmed, have to confirm the association which eventually will automatically confirm the existing account as well. The reason is simple: Consider the scenario where some stranger uses your email address and registers an account through the normal process. This means that a stranger knows the password for that account. You on the other side decide someday to sign in through an external provider that uses the same email address. If we automatically confirm the account that already exists in the database then the stranger has instant access to your account through the normal authentication process (username & password)
  • Now that we know the rules let’s take a look at the flows running during registration and external providers association process.

    • When registering a new account through the normal process, a confirmation email is sent to the email address used. The email contains a link that changes the user account EmailConfirmed status to True
    • When trying to associate an external login provider with an existing confirmed account that has a different email address, a confirmation email is sent to that address. Clicking on the link in the email adds the login provider to the existing account
    • When trying to associate an external login provider with an existing un-confirmed account that has a different email address, nothing happens. A message is shown to the user that the existing account’s email address needs to be confirmed first
    • Registering a new account through an external login provider marks instantly the account as confirmed and hence no confirmation email is sent

    External provider registration strategy implementation

    Time to check how all the above requirements are implemented. We will start by making email confirmation required. In the Startup when you configure Identity you need to set the following:

    services.AddIdentity<IdentityUser, IdentityRole>(config =>
    {
        config.SignIn.RequireConfirmedEmail = true;
    })
    .AddEntityFrameworkStores<IdentityDbContext>()
    .AddDefaultTokenProviders();
    

    There are two controllers related to user accounts, the default AccountController and the SocialAccountController that deals with all the external provider related tasks. In the Login action of the AccountController we will add a check to see if the user can sign in.

    if (await _userManager.CheckPasswordAsync(user, model.Password))
    {
        // Rule #1
        if (!await _signInManager.CanSignInAsync(user))
        {
            result.Status = Status.Error;
            result.Data = "<li>Email confirmation required</li>";
    
            return result;
        }
    // code omitted 
    

    Always remember to use the CanSignInAsync method of the SignInManager because it knows to run all validations have been configured in the Identity system.

    public virtual async Task<bool> CanSignInAsync(TUser user)
    {
        if (Options.SignIn.RequireConfirmedEmail && !(await UserManager.IsEmailConfirmedAsync(user)))
        {
            Logger.LogWarning(0, "User {userId} cannot sign in without a confirmed email.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        if (Options.SignIn.RequireConfirmedPhoneNumber && !(await UserManager.IsPhoneNumberConfirmedAsync(user)))
        {
            Logger.LogWarning(1, "User {userId} cannot sign in without a confirmed phone number.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        if (Options.SignIn.RequireConfirmedAccount && !(await _confirmation.IsConfirmedAsync(UserManager, user)))
        {
            Logger.LogWarning(4, "User {userId} cannot sign in without a confirmed account.", await UserManager.GetUserIdAsync(user));
            return false;
        }
        return true;
    }
    

    Now if you create a new user through the default process its EmailConfirmed column in the AspNetUsers table will be False.

    And if you try to sign in with your credential you will see the following error message.

    Next thing we need to do is add email support to our app. This is very easy to accomplish using the SendGrid service. It’s totally free so go ahead and create a new account. You will be able to send at least 100 emails per day so it’s more than enough for development purposes. After creating the account open the API Keys view under the Settings menu on the left and create a new API key.

    Copy your API Key. You also need your account’s username which you can find in the Account Details menu item. Next run the following commands in the Package Manager Console:

    dotnet user-secrets set SendGridUser "<your-sendgrid-username>"
    dotnet user-secrets set SendGridKey "<your-sendgrid-apikey>"
    

    When registering a new account through the normal process a new email confirmation token is created and sent to the email address used. This is done in the AccountController Register action.

    var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
    var callbackUrl = Url.Action("ConfirmEmail", "Account",
        values: new { userId = user.Id, code = code },
        protocol: Request.Scheme);
    
    await _emailSender.SendEmailAsync(user.Email, "Confirm your email",
        $"Please confirm your account by <a href="https://chsakell.com/2019/07/28/asp-net-core-identity-series-external-provider-authentication-registration-strategy/{HtmlEncoder.Default.Encode(callbackUrl)}">clicking here</a>.");
    
    return new ResultVM
    {
        Status = Status.Success,
        Message = "Email confirmation is pending",
        Data = user
    };
    

    You can generate several types of tokens as you can see from the implementation of the GenerateEmailConfirmationTokenAsync method.

    public virtual Task<string> GenerateEmailConfirmationTokenAsync(TUser user)
    {
        ThrowIfDisposed();
        return GenerateUserTokenAsync(user, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose);
    }
    

    The Url.Action will create a link to the ConfirmEmail action of the same controller. This action calls the UserManager ConfirmEmailsAsync method which validates the token and if valid will update the user record in the database as well.

    public virtual async Task<IdentityResult> ConfirmEmailAsync(TUser user, string token)
    {
        ThrowIfDisposed();
        var store = GetEmailStore();
        if (user == null)
        {
            throw new ArgumentNullException(nameof(user));
        }
    
        if (!await VerifyUserTokenAsync(user, Options.Tokens.EmailConfirmationTokenProvider, ConfirmEmailTokenPurpose, token))
        {
            return IdentityResult.Failed(ErrorDescriber.InvalidToken());
        }
        await store.SetEmailConfirmedAsync(user, true, CancellationToken);
        return await UpdateUserAsync(user);
    }
    

    By the way, the confirmation email should look like this.

    Let’s understand what happens when you add support for an external provider authentication through an extension method such as services.AddAuthentication().AddGoogle. We will examine the case of the Google provider. What happens is that the builder registers a handler of type Microsoft.AspNetCore.Authentication.OAuth.OAuthHandler.

    public static class GoogleExtensions
    {
        // code omitted 
    
        public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<GoogleOptions> configureOptions)
            => builder.AddOAuth<GoogleOptions, GoogleHandler>(authenticationScheme, displayName, configureOptions);
    }
    

    Each of these handlers has some type of default OAuthOptions configuration, so now you know where the /signin-google, signin-facebook etc.. comes from.

    public class GoogleOptions : OAuthOptions
    {
        /// <summary>
        /// Initializes a new <see cref="GoogleOptions"/>.
        /// </summary>
        public GoogleOptions()
        {
            CallbackPath = new PathString("/signin-google");
            AuthorizationEndpoint = GoogleDefaults.AuthorizationEndpoint;
            TokenEndpoint = GoogleDefaults.TokenEndpoint;
            UserInformationEndpoint = GoogleDefaults.UserInformationEndpoint;
            Scope.Add("openid");
            Scope.Add("profile");
            Scope.Add("email");
    
            ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
            ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
            ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
            ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");
            ClaimActions.MapJsonKey("urn:google:profile", "link");
            ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
        }
    
        /// <summary>
        /// access_type. Set to 'offline' to request a refresh token.
        /// </summary>
        public string AccessType { get; set; }
    }
    

    These handlers know how to create a ChallengeUrl which is the Url that redirects the user to the external provider for signing in.

    protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri)
    {
        // Google Identity Platform Manual:
        // https://developers.google.com/identity/protocols/OAuth2WebServer
    
        var queryStrings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        queryStrings.Add("response_type", "code");
        queryStrings.Add("client_id", Options.ClientId);
        queryStrings.Add("redirect_uri", redirectUri);
    
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.ScopeKey, FormatScope, Options.Scope);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.AccessTypeKey, Options.AccessType);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.ApprovalPromptKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.PromptParameterKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.LoginHintKey);
        AddQueryString(queryStrings, properties, GoogleChallengeProperties.IncludeGrantedScopesKey, v => v?.ToString().ToLower(), (bool?)null);
    
        var state = Options.StateDataFormat.Protect(properties);
        queryStrings.Add("state", state);
    
        var authorizationEndpoint = QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings);
        return authorizationEndpoint;
    }
    

    When a OAuth handler fires, its CreateTicketAsync method runs and if the response payload from the external provider is valid then an AuthenticationTicket is returned.

    protected override async Task<AuthenticationTicket> CreateTicketAsync(
        ClaimsIdentity identity,
        AuthenticationProperties properties,
        OAuthTokenResponse tokens)
    {
        // Get the Google user
        var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
    
        var response = await Backchannel.SendAsync(request, Context.RequestAborted);
        if (!response.IsSuccessStatusCode)
        {
            throw new HttpRequestException($"An error occurred when retrieving Google user information ({response.StatusCode}). Please check if the authentication information is correct.");
        }
    
        using (var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
        {
            var context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme, Options, Backchannel, tokens, payload.RootElement);
            context.RunClaimActions();
            await Events.CreatingTicket(context);
            return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
        }
    }
    

    As we mentioned, each OAuth handler has some default values and one of them is the AuthenticationScheme.

    public static partial class GoogleDefaults
    {
        public const string AuthenticationScheme = "Google";
        public static readonly string AuthorizationEndpoint;
        public static readonly string DisplayName;
        public static readonly string TokenEndpoint;
        public static readonly string UserInformationEndpoint;
    }
    

    The AuthenticationBuilder.AddOAuth extension method ends up adding mappings of Authentication schemes with the relative handlers..

    public static AuthenticationBuilder AddOAuth<TOptions, THandler>(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<TOptions> configureOptions)
        where TOptions : OAuthOptions, new()
        where THandler : OAuthHandler<TOptions>
    {
        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<TOptions>, OAuthPostConfigureOptions<TOptions, THandler>>());
        return builder.AddRemoteScheme<TOptions, THandler>(authenticationScheme, displayName, configureOptions);
    }
    

    When you run the app you will see all external providers that have been setup on the login screen.

    This is an API call to the Providers action of the SocialAccountController that invokes the _signInManager.GetExternalAuthenticationSchemesAsync method.

    [HttpGet]
    public async Task<IActionResult> Providers()
    {
        var schemes = await _signInManager.GetExternalAuthenticationSchemesAsync();
    
        return Ok(schemes.Select(s => s.DisplayName).ToList());
    }
    

    The GetExternalAuthenticationSchemesAsync method returns all the schemes that have been added for external login providers.

    public virtual async Task<IEnumerable<AuthenticationScheme>> GetExternalAuthenticationSchemesAsync()
    {
        var schemes = await _schemes.GetAllSchemesAsync();
        return schemes.Where(s => !string.IsNullOrEmpty(s.DisplayName));
    }
    

    This concludes how the authentication handlers are added and work behind the scenes. Each of the external providers icon you see on the login screen points to the SocialAccountController Login action, passing the respective provider parameter value.

    [HttpGet]
    public IActionResult Login(string provider, string returnUrl = null)
    {
        var redirectUrl = Url.Action("Callback", "SocialAccount");
        var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
        return new ChallengeResult(provider, properties);
    }
    

    The redirectUrl is the action method that will handle the response redirect from the external provider and in our case is the Callback action of the same controller. When the external provider’s response hits this action, first we retrieve the provider’s info.

    var info = await _signInManager.GetExternalLoginInfoAsync();
    

    Next it tries to sign in using the login provider name and its provider key.

    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
                    isPersistent: false, bypassTwoFactor: true);
    

    Let’s pause a little bit here to see what this means. A user account or an AspNetUsers table record, can be bound with multiple external providers through the AspNetUserLogins table. In the following screenshot you can see that my account has 3 external providers that may use different email addresses from the original one.

    The provider key is unique for every user in the external provider. The ExternalLoginSignInAsync method tries to find a user that has added a login provider having a specific provider key.

    public virtual async Task<SignInResult> ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor)
    {
        var user = await UserManager.FindByLoginAsync(loginProvider, providerKey);
        if (user == null)
        {
            return SignInResult.Failed;
        }
    
        var error = await PreSignInCheck(user);
        if (error != null)
        {
            return error;
        }
        return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor);
    }
    

    If a user with this provider details found, the PreSignInCheck method will check if the user can sign in.

    protected virtual async Task<SignInResult> PreSignInCheck(TUser user)
    {
        if (!await CanSignInAsync(user))
        {
            return SignInResult.NotAllowed;
        }
        if (await IsLockedOut(user))
        {
            return await LockedOut(user);
        }
        return null;
    }
    

    What follows next in the Callback action is the business logic we have decided to implement.

    • On Success: It means that the user has already added this external provider and the account is confirmed
      var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
                      isPersistent: false, bypassTwoFactor: true);
      if (result.Succeeded)
      {
          return LocalRedirect(returnUrl);
      }
      // code omitted
      
    • Failure & account with email address doesn’t exist:The user is redirected to the register view to choose either to create a new account or associate it with an existing one
    • Failure & account with email exists but is unconfirmed: A confirmation email is sent. If the user clicks the email link two actions will follow: The account will be confirmed and the external provider will be added as well. The action that will handle this is the ConfirmExternalProvider action in the Account controller.
      // RULE #5
      if (!userDb.EmailConfirmed)
      {
          var token = await _userManager.GenerateEmailConfirmationTokenAsync(userDb);
      
          var callbackUrl = Url.Action("ConfirmExternalProvider", "Account",
              values: new
              {
                  userId = userDb.Id,
                  code = token,
                  loginProvider = info.LoginProvider,
                  providerDisplayName = info.LoginProvider,
                  providerKey = info.ProviderKey
              },
              protocol: Request.Scheme);
      
          await _emailSender.SendEmailAsync(userDb.Email, $"Confirm {info.ProviderDisplayName} external login",
              $"Please confirm association of your {info.ProviderDisplayName} account by clicking <a href="https://chsakell.com/2019/07/28/asp-net-core-identity-series-external-provider-authentication-registration-strategy/{HtmlEncoder.Default.Encode(callbackUrl)}">here</a>.");
      
          return LocalRedirect(
              $"{returnUrl}?message=External account association with {info.ProviderDisplayName} is pending.Please check your email");
      }
      

      An external provider association email confirmaction looks like this.

    • Failure & account with email exists and is already confirmed: This means that we can proceed by adding the login provider to that account.
      // Add the external provider
      await _userManager.AddLoginAsync(userDb, info);
      
      await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey,
      isPersistent: false, bypassTwoFactor: true);
      
      return LocalRedirect(
          $"{returnUrl}?message={info.ProviderDisplayName} has been added successfully");
      

    When signing in with an external provider it’s important to use the ExternalLoginSignInAsync method. This will add the AuthenticationMethod claim to the user and you will be able to find the provider the user is signed in to the app.

    [HttpGet]
    public UserStateVM Authenticated()
    {
        return new UserStateVM
        {
            IsAuthenticated = User.Identity.IsAuthenticated,
            Username = User.Identity.IsAuthenticated ? User.Identity.Name : string.Empty,
            AuthenticationMethod = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.AuthenticationMethod)?.Value,
            DisplaySetPassword = User.Identity.IsAuthenticated 
                                         && !(await _userManager.HasPasswordAsync(
                                             (await _userManager.GetUserAsync(User))
                                             ))
        };
    }
    

    External provider association with an existing account

    When you sign in with an external provider and no user with the email used in the provider found, the app redirects you to the register page giving you two options. The first one is to simply provide a username and create a new account.

    Hitting register will invoke the associate action of the SocialAccountController passing the following params on the request’s body. Notice that all the required provider’s details are available (read from the query string) to add the login provider to the new user account to be created.

    On the other hand, if you click the associate an existing account checkbox and choose to associate the external provider with an existing account, you need to enter the email address of the existing account you wish to associate your provider login.

    This time the associateExistingAccount param is True. The code will check if the existing account exists in the database and if it doesn’t it will return a simple message. If it does exist it will check its email confirmation status. If the email isn’t confirmed again it will send back a simple message that the existing account should be confirmed first before associating an external provider (Rule #5). If the account is confirmed then a confirmation email will be sent to the existing account’s email address in order to confirm and add the external provider login (Rule #4).

    if (userDb != null)
    {
        // Rule #5
        if (!userDb.EmailConfirmed)
        {
            return new ResultVM
            {
                Status = Status.Error,
                Message = "Invalid data",
                Data = $"<li>Associated account (<i>{associate.AssociateEmail}</i>) hasn't been confirmed yet.</li><li>Confirm the account and try again</li>"
            };
        }
    
        // Rule #4
        var token = await _userManager.GenerateEmailConfirmationTokenAsync(userDb);
    
        var callbackUrl = Url.Action("ConfirmExternalProvider", "Account",
            values: new
            {
                userId = userDb.Id,
                code = token,
                loginProvider = associate.LoginProvider,
                providerDisplayName = associate.LoginProvider,
                providerKey = associate.ProviderKey
            },
            protocol: Request.Scheme);
    
        await _emailSender.SendEmailAsync(userDb.Email, $"Confirm {associate.ProviderDisplayName} external login",
            $"Please confirm association of your {associate.ProviderDisplayName} account by clicking <a href="https://chsakell.com/2019/07/28/asp-net-core-identity-series-external-provider-authentication-registration-strategy/{HtmlEncoder.Default.Encode(callbackUrl)}">here</a>.");
    
        return new ResultVM
        {
            Status = Status.Success,
            Message = "External account association is pending. Please check your email"
        };
    }
    

    That’s it, we finished! I encourage you run the AspNetCoreIdentity app and test all the authentication methods we mentioned in the post. One thing that I have intentionally left for the end, is to advise you to never share the external provider details in emails. We used it in our app for simplicity but in a production environment, you should use some type of encryption and/or a database store. Also, don’t forget that when registering a new account through an external provider the record in the AspNetUsers database table has NULL value for the PasswordHash value. This means that if the external provider is not available (Facebook & Instagram have lots of incidents lately..), the users won’t be able to sign in. For this reason, you should create a view for the user to set a password as well. In the following screenshot, the user has registered and authenticated via a Google account which means no password has been set yet.

    The information that the user hasn’t set any password comes from the Authenticated action.

    [HttpGet]
    public async Task<IActionResult> Authenticated()
    {
        return Ok(new
        {
            User.Identity.IsAuthenticated,
            Username = User.Identity.IsAuthenticated ? User.Identity.Name : string.Empty,
            AuthenticationMethod = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.AuthenticationMethod)?.Value,
            DisplaySetPassword = User.Identity.IsAuthenticated 
                                 && !(await _userManager.HasPasswordAsync(
                                     (await _userManager.GetUserAsync(User))
                                     ))
        });
    }
    

    If displaySetPassword is true then the user is redirected to the /password route to set the account’s password.

    Clicking the Save password button will hit the ManagePassword AccountController action.

    [HttpPost]
    [Authorize]
    public async Task<ResultVM> ManagePassword([FromBody] UpdatePasswordVM updatePassword)
    {
        if (ModelState.IsValid)
        {
            var user = await _userManager.GetUserAsync(User);
    
            // This will set the password only if it's NULL
            var result = await _userManager.AddPasswordAsync(user, updatePassword.Password);
    
            if (result.Succeeded)
            {
                return new ResultVM
                {
                    Status = Status.Success,
                    Message = "Password has been updated successfully"
                };
            }
    
            var errors = result.Errors.Select(e => e.Description).Select(e => "<li>" + e + "</li>");
    
            return new ResultVM
            {
                Status = Status.Error,
                Message = "Invalid data",
                Data = string.Join("", errors)
            };
        }
        else
        {
            var errors = ModelState.Keys.Select(e => "<li>" + e + "</li>");
            return new ResultVM
            {
                Status = Status.Error,
                Message = "Invalid data",
                Data = string.Join("", errors)
            };
        }
    }
    

    We have seen how to add external login provider authentication for many providers and how their handlers work behind the scenes. We also implemented an external login provider registration strategy that allows the user to choose either to create a new account from the external provider login details or associate it with an existing one.

    In case you find my blog’s content interesting, register your email to receive notifications of new posts and follow chsakell’s Blog on its Facebook or Twitter accounts.

    Facebook Twitter
    .NET Web Application Development by Chris S.
    facebook twitter-small
    twitter-small

    Categories: asp.net core, Best practices, WCF

    Tags: ,




    Source link