'Use Azure B2C as PRIMARY authentication provider in abp framework web app

I have an abp framework web application using the standard authentication provider based on Asp.NET Core Identity.

I want to replace Asp.NET Core Identity abp implementation to Azure B2C as primary authentication provider and manage its own Identity store and external providers.

I'm thinking on Azure B2C because:

  • It is a PaaS service auto-managed by Azure and the implementation and maintenance should be easier than identity server 4.
  • I don't need to store credentials in the App database.

On the other hand, and here my question. How to replace the abp framework identity store? overwrite the login/ logout/ signup/ password recovery/ ... uses cases? and integrate with multi-tenancy and other modules?.

thanks so much for your thoughts,

abp


Solution 1:[1]

This is possible and I did. Read and try to implement the articles in abp documentation:

  • Azure Active Directory Authentication
  • Customize the Login Page.
  • Customize the SignIn Manager

To understand the concept and then basically you will need to replace the azuread library with azureb2c, I managed to do this using:

Alternative Approach: AddOpenIdConnect

Tip: the identity server will continue to exist in your application, authenticating against azureb2c just creates an local user whit external authentication in your application, if you only want to use azureb2c you can make the default login page always redirect to the Azureb2c authentication page and create/authenticate user after he comme back.

Sorry for my english.

See code:

appsettings.json , replace xxx with your own settings

"AzureAdB2C": {
"ClientId": "xxx",
"Tenant": "xxx.onmicrosoft.com",
"AzureAdB2CInstance": "https://xxx.b2clogin.com",
"SignUpSignInPolicyId": "B2C_1_Logon_Signup",
"ResetPasswordPolicyId": "B2C_1_resetpass",
"EditProfilePolicyId": "B2C_1_edit",
"RedirectUri": "https://xxx:443/signin-oidc", //,
"ClientSecret": "xxx"
}

Configure your module in section configureservices change ClaimTypes to AbpClaimTypes:

//custom sign in configureservices
context.Services.GetObject<IdentityBuilder>().AddSignInManager<CustomSignInManager>();
//configure auth
private void ConfigureAuthentication(ServiceConfigurationContext context, 
IConfiguration configuration){
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("sub", 
ClaimTypes.NameIdentifier);
// JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("emails", ClaimTypes.Email); 
//not working
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Add("emails", AbpClaimTypes.Email);
context.Services.AddAuthentication()
.AddIdentityServerAuthentication(options =>{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = false;
options.ApiName = "test";
}).AddAzureAdB2C(options => configuration.GetSection("AzureAdB2C").Bind(options)).AddCookie();
}

Openid needed classes:

public class AzureAdB2COptions
{
    public const string PolicyAuthenticationProperty = "Policy";
    public string ClientId { get; set; }
    public string AzureAdB2CInstance { get; set; }
    public string Tenant { get; set; }
    public string SignUpSignInPolicyId { get; set; }
    public string SignInPolicyId { get; set; }
    public string SignUpPolicyId { get; set; }
    public string ResetPasswordPolicyId { get; set; }
    public string EditProfilePolicyId { get; set; }
    public string RedirectUri { get; set; }
    public string DefaultPolicy => SignUpSignInPolicyId;
    public string Authority => $"{AzureAdB2CInstance}/tfp/{Tenant}/{DefaultPolicy}/v2.0";
    public string ClientSecret { get; set; }
    public string ApiUrl { get; set; }
    public string ApiScopes { get; set; }
}
public class CustomSignInManager : SignInManager<Volo.Abp.Identity.IdentityUser>
{
    private const string LoginProviderKey = "LoginProvider";
    private const string XsrfKey = "XsrfId";
    public CustomSignInManager(
        UserManager<Volo.Abp.Identity.IdentityUser> userManager,
        Microsoft.AspNetCore.Http.IHttpContextAccessor contextAccessor,
        IUserClaimsPrincipalFactory<Volo.Abp.Identity.IdentityUser> claimsFactory,
        Microsoft.Extensions.Options.IOptions<IdentityOptions> optionsAccessor,
        Microsoft.Extensions.Logging.ILogger<SignInManager<Volo.Abp.Identity.IdentityUser>> logger,
        Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemes,
        IUserConfirmation<Volo.Abp.Identity.IdentityUser> confirmation) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
    {
    }
    // https://github.com/aspnet/Identity/blob/feedcb5c53444f716ef5121d3add56e11c7b71e5/src/Identity/SignInManager.cs#L589-L624
    public override async Task<ExternalLoginInfo> GetExternalLoginInfoAsync(string expectedXsrf = null)
    {
        var auth = await Context.AuthenticateAsync(IdentityConstants.ExternalScheme);
        var items = auth?.Properties?.Items;
        if (auth?.Principal == null || items == null || !items.ContainsKey(LoginProviderKey))
        {
            return null;
        }

        if (expectedXsrf != null)
        {
            if (!items.ContainsKey(XsrfKey))
            {
                return null;
            }
            var userId = items[XsrfKey] as string;
            if (userId != expectedXsrf)
            {
                return null;
            }
        }

        var providerKey = auth.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
        var provider = items[LoginProviderKey] as string;
        if (providerKey == null || provider == null)
        {
            return null;
        }

        var providerDisplayName = (await GetExternalAuthenticationSchemesAsync()).FirstOrDefault(p => p.Name == provider)?.DisplayName
                                  ?? provider;
        return new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName)
        {
            AuthenticationTokens = auth.Properties.GetTokens()
        };
    }


}

public static class AzureAdB2CAuthenticationBuilderExtensions
{
    public static AuthenticationBuilder AddAzureAdB2C(this AuthenticationBuilder builder)
        => builder.AddAzureAdB2C(_ =>
        {
        });

    public static AuthenticationBuilder AddAzureAdB2C(this AuthenticationBuilder builder, Action<AzureAdB2COptions> configureOptions)
    {
        builder.Services.Configure(configureOptions);
        builder.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, OpenIdConnectOptionsSetup>();
        builder.AddOpenIdConnect();
        return builder;
    }

    public class OpenIdConnectOptionsSetup : IConfigureNamedOptions<OpenIdConnectOptions>
    {

        public OpenIdConnectOptionsSetup(IOptions<AzureAdB2COptions> b2cOptions)
        {
            AzureAdB2COptions = b2cOptions.Value;
        }

        public AzureAdB2COptions AzureAdB2COptions { get; set; }

        public void Configure(string name, OpenIdConnectOptions options)
        {
            options.ClientId = AzureAdB2COptions.ClientId;
            options.Authority = AzureAdB2COptions.Authority;
            options.UseTokenLifetime = true;
            options.TokenValidationParameters = new TokenValidationParameters() { NameClaimType = "name"  };
            options.Scope.Add("email");
            options.RequireHttpsMetadata = false;
            options.SaveTokens = true;
            options.GetClaimsFromUserInfoEndpoint = true;
            //options.ResponseType = OpenIdConnectResponseType.CodeIdToken;



            options.Events = new OpenIdConnectEvents()
            {
                OnTokenValidated = (async context =>
                {
                    var debugIdentityPrincipal = context.Principal.Identity;
                    var claimsFromOidcProvider = context.Principal.Claims.ToList();
                    await Task.CompletedTask;
                }),
                OnRedirectToIdentityProvider = OnRedirectToIdentityProvider,
                OnRemoteFailure = OnRemoteFailure,
                OnAuthorizationCodeReceived = OnAuthorizationCodeReceived
            };
        }

        public void Configure(OpenIdConnectOptions options)
        {
            Configure(Options.DefaultName, options);
        }

        public Task OnRedirectToIdentityProvider(RedirectContext context)
        {
            var defaultPolicy = AzureAdB2COptions.DefaultPolicy;
            if (context.Properties.Items.TryGetValue(AzureAdB2COptions.PolicyAuthenticationProperty, out var policy) &&
                !policy.Equals(defaultPolicy))
            {
                context.ProtocolMessage.Scope = OpenIdConnectScope.OpenIdProfile;
                context.ProtocolMessage.ResponseType = OpenIdConnectResponseType.IdToken;
                context.ProtocolMessage.IssuerAddress = context.ProtocolMessage.IssuerAddress.ToLower().Replace(defaultPolicy.ToLower(), policy.ToLower());
                context.Properties.Items.Remove(AzureAdB2COptions.PolicyAuthenticationProperty);
            }
            else if (!string.IsNullOrEmpty(AzureAdB2COptions.ApiUrl))
            {
                context.ProtocolMessage.Scope += $" offline_access {AzureAdB2COptions.ApiScopes}";
                context.ProtocolMessage.ResponseType = OpenIdConnectResponseType.CodeIdToken;
            }
            return Task.FromResult(0);
        }

        public Task OnRemoteFailure(RemoteFailureContext context)
        {
            context.HandleResponse();
            // Handle the error code that Azure AD B2C throws when trying to reset a password from the login page 
            // because password reset is not supported by a "sign-up or sign-in policy"
            if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("AADB2C90118"))
            {
                // If the user clicked the reset password link, redirect to the reset password route
                context.Response.Redirect("/Session/ResetPassword");
            }
            else if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("access_denied"))
            {
                context.Response.Redirect("/");
            }
            else
            {
                context.Response.Redirect("/Home/Error?message=" + Uri.EscapeDataString(context.Failure.Message));
            }
            return Task.FromResult(0);
        }

        public async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
        {
            // Use MSAL to swap the code for an access token
            // Extract the code from the response notification
            var code = context.ProtocolMessage.Code;

            string signedInUserID = context.Principal.FindFirst(ClaimTypes.NameIdentifier).Value;
            IConfidentialClientApplication cca = ConfidentialClientApplicationBuilder.Create(AzureAdB2COptions.ClientId)
                .WithB2CAuthority(AzureAdB2COptions.Authority)
                .WithRedirectUri(AzureAdB2COptions.RedirectUri)
                .WithClientSecret(AzureAdB2COptions.ClientSecret)
                .Build();
            new MSALStaticCache(signedInUserID, context.HttpContext).EnablePersistence(cca.UserTokenCache);

            try
            {
                AuthenticationResult result = await cca.AcquireTokenByAuthorizationCode(AzureAdB2COptions.ApiScopes.Split(' '), code)
                    .ExecuteAsync();


                context.HandleCodeRedemption(result.AccessToken, result.IdToken);
            }
            catch (Exception ex)
            {
                //TODO: Handle
                throw;
            }
        }
    }
}

And Finally create : \Pages\Account\Login.cshtml.cs to replace original login you will need change login.cshtml too.

    using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Volo.Abp.Account.Settings;
using Volo.Abp.Auditing;
using Volo.Abp.Identity;
using Volo.Abp.Security.Claims;
using Volo.Abp.Settings;
using Volo.Abp.Uow;
using Volo.Abp.Validation;
using IdentityUser = Volo.Abp.Identity.IdentityUser;

namespace Volo.Abp.Account.Web.Pages.Account
{
    public class CustomLoginModel : LoginModel
    {
     
        public CustomLoginModel(
                Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider,
                Microsoft.Extensions.Options.IOptions<Volo.Abp.Account.Web.AbpAccountOptions> accountOptions)
       : base(schemeProvider, accountOptions)
        {
           
        }

        public override async Task<IActionResult> OnGetAsync()
        {
            string provider = "OpenIdConnect";
            var redirectUrl = Url.Page("./Login", pageHandler: "ExternalLoginCallback", values: new { ReturnUrl, ReturnUrlHash });
            var properties = SignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            properties.Items["scheme"] = provider;
            return await Task.FromResult(Challenge(properties, provider));
        }

        public override async Task<IActionResult> OnGetExternalLoginCallbackAsync(string returnUrl = "", string returnUrlHash = "", string remoteError = null)
        {
            //TODO: Did not implemented Identity Server 4 sample for this method (see ExternalLoginCallback in Quickstart of IDS4 sample)
            /* Also did not implement these:
             * - Logout(string logoutId)
             */
           
            if (remoteError != null)
            {
                Logger.LogWarning($"External login callback error: {remoteError}");
                return RedirectToPage("./Login");
            }

            var loginInfo = await SignInManager.GetExternalLoginInfoAsync();
            if (loginInfo == null)
            {
                Logger.LogWarning("External login info is not available");
                return RedirectToPage("./Login");
            }

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

            if (result.IsLockedOut)
            {
                throw new UserFriendlyException("Cannot proceed because user is locked out!");
            }

            if (result.Succeeded)
            {
                return RedirectSafely(returnUrl, returnUrlHash);
            }

            //TODO: Handle other cases for result!

            // Get the information about the user from the external login provider
            //var info = await SignInManager.GetExternalLoginInfoAsync();
            //if (info == null)
            //{
            //    throw new ApplicationException("Error loading external login information during confirmation.");
            //}

            var user = await CreateExternalUserAsync(loginInfo);

            await SignInManager.SignInAsync(user, false);
            return RedirectSafely(returnUrl, returnUrlHash);
        }

        protected override async Task<IdentityUser> CreateExternalUserAsync(ExternalLoginInfo info)
        {
            var emailAddress = info.Principal.FindFirstValue(AbpClaimTypes.Email);

            var user = new IdentityUser(GuidGenerator.Create(), emailAddress, emailAddress, CurrentTenant.Id);

            CheckIdentityErrors(await UserManager.CreateAsync(user));
            CheckIdentityErrors(await UserManager.SetEmailAsync(user, emailAddress));
            CheckIdentityErrors(await UserManager.AddLoginAsync(user, info));
            CheckIdentityErrors(await UserManager.AddDefaultRolesAsync(user));

            return user;
        }

        protected override async Task ReplaceEmailToUsernameOfInputIfNeeds()
        {
            if (!ValidationHelper.IsValidEmailAddress(LoginInput.UserNameOrEmailAddress))
            {
                return;
            }

            var userByUsername = await UserManager.FindByNameAsync(LoginInput.UserNameOrEmailAddress);
            if (userByUsername != null)
            {
                return;
            }

            var userByEmail = await UserManager.FindByEmailAsync(LoginInput.UserNameOrEmailAddress);
            if (userByEmail == null)
            {
                return;
            }

            LoginInput.UserNameOrEmailAddress = userByEmail.UserName;
        }
    }
}

Solution 2:[2]

I am thinking of doing something similar. I want to remove IdentityServer, then add Azure ADB2C. However once you still need to store at least the User in abpuser table (copy from azure ADB2C) on login. This is a bit complex because you have to override quite a few services...

I was hopping that someone has work on this because I don't want password in the DB either.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 walton Sobrinho
Solution 2 Ralph Lavaud