multi-tenantauth0abp-framework

ABP.IO - MultiTenancy - Setting Tenant from External IDP


I am trying to configure Auth0 as an external login provider in my ABP.IO application (MVC with integrated identity server). I've got it working so that I can log in fine, but what I can't figure out is how to set the tenant in the ABP side.

What I came up with is a rule on the Auth0 side to populate the TenantId as a claim in the id token, so I can parse that in my custom SingInManager in the GetExternalLoginInfoAsync method, like so:

string tenantId = auth.Principal.FindFirstValue("https://example.com/tenantId");

I'm just having a hard time figuring out what to do with it from there. The assumption is that users will be configured to authenticate via Auth0 and the users will get created locally on first login (which, again, is working EXCEPT for the Tenant part).


Solution

  • Alright, here is the workaround I have in place, and it SHOULD be transferable to any external login system that you are depending on. I'm not sure if this is the correct way of doing this, so if anybody else wants to chip in with a more efficient system I am all ears.

    Anyway, my workflow assumes that you have, like I did, created a mechanism for the TenantId to be sent from the external IDP. For this, I used the Organizations feature in Auth0 and added the TenantId as metadata, then I created an Action in Auth0 to attach that metadata as a claim to be used on the ABP side.

    In ABP, I followed this article to override the SignInManager: https://community.abp.io/articles/how-to-customize-the-signin-manager-3e858753

    As in the article, I overrode the GetExternalLoginInfoAsync method of the sign in manager and added the following lines to pull the TenantId out of the Auth0 claims and add it back in using the pre-defined AbpClaimTypes.TenantId value.

    EDIT: I also had to override the ExternalLoginSignInAsync method to account for multi-tenancy (otherwise it kept trying to recreate the users and throwing duplicate email errors). I'll post the full class below with my added stuff in comments:

    public class CustomSignInManager : Microsoft.AspNetCore.Identity.SignInManager<Volo.Abp.Identity.IdentityUser>
        {
            private const string LoginProviderKey = "LoginProvider";
            private const string XsrfKey = "XsrfId";
            private readonly IDataFilter _dataFilter;
    
            public CustomSignInManager(
                IDataFilter dataFilter,
                Microsoft.AspNetCore.Identity.UserManager<Volo.Abp.Identity.IdentityUser> userManager,
                Microsoft.AspNetCore.Http.IHttpContextAccessor contextAccessor,
                Microsoft.AspNetCore.Identity.IUserClaimsPrincipalFactory<Volo.Abp.Identity.IdentityUser> claimsFactory,
                Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Identity.IdentityOptions> optionsAccessor,
                Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Identity.SignInManager<Volo.Abp.Identity.IdentityUser>> logger,
                Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemes,
                Microsoft.AspNetCore.Identity.IUserConfirmation<Volo.Abp.Identity.IdentityUser> confirmation)
                : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation)
            {
                _dataFilter = dataFilter;
            }
    
            /// <summary>
            /// Gets the external login information for the current login, as an asynchronous operation.
            /// </summary>
            /// <param name="expectedXsrf">Flag indication whether a Cross Site Request Forgery token was expected in the current request.</param>
            /// <returns>The task object representing the asynchronous operation containing the <see name="ExternalLoginInfo"/>
            /// for the sign-in attempt.</returns>
            public override async Task<Microsoft.AspNetCore.Identity.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;
    
                /* Begin tenantId claim search */
                string tenantId = auth.Principal.FindFirstValue("https://example.com/tenantId"); //pull the tenantId claim if it exists
                if(!string.IsNullOrEmpty(tenantId))
                {
                    auth.Principal.Identities.FirstOrDefault().AddClaim(new Claim(AbpClaimTypes.TenantId, tenantId)); //if there is a tenantId, add the AbpClaimTypes.TenantId claim back into the principal
                }
                /* End tenantId claim search */
    
                var eli = new ExternalLoginInfo(auth.Principal, provider, providerKey, providerDisplayName)
                {
                    AuthenticationTokens = auth.Properties.GetTokens(),
                    AuthenticationProperties = auth.Properties
                };
    
    
    
                return eli;
            }
    
            /// <summary>
            /// Signs in a user via a previously registered third party login, as an asynchronous operation.
            /// </summary>
            /// <param name="loginProvider">The login provider to use.</param>
            /// <param name="providerKey">The unique provider identifier for the user.</param>
            /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
            /// <param name="bypassTwoFactor">Flag indicating whether to bypass two factor authentication.</param>
            /// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult"/>
            /// for the sign-in attempt.</returns>
            public override async Task<SignInResult> ExternalLoginSignInAsync(string loginProvider, string providerKey, bool isPersistent, bool bypassTwoFactor)
            {
                Volo.Abp.Identity.IdentityUser user = null; //stage the user variable as null
                using (_dataFilter.Disable<IMultiTenant>()) //disable the tenantid filters so we can search all logins for the expected key
                {
                     user = await UserManager.FindByLoginAsync(loginProvider, providerKey); //search logins for the expected key
                }
                    
                if (user == null)
                {
                    return SignInResult.Failed;
                }
    
                var error = await PreSignInCheck(user);
                if (error != null)
                {
                    return error;
                }
                return await SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor);
            }
    
        }
    

    Once that was done, I tracked down where the GetExternalLoginInfoAsync was being utilized and figured out I had to override the CreateExternalUserAsync method inside of the LoginModel for the Login page. To that end, I followed the directions in this article for creating a CustomLoginModel.cs and Login.cshtml : https://community.abp.io/articles/hide-the-tenant-switch-of-the-login-page-4foaup7p

    So, my Auth0LoginModel class looks like this:

    public class Auth0LoginModel : LoginModel
        {
            public Auth0LoginModel(IAuthenticationSchemeProvider schemeProvider, IOptions<AbpAccountOptions> accountOptions, IOptions<IdentityOptions> identityOptions) : base(schemeProvider, accountOptions, identityOptions)
            {
            }
    
            protected override async Task<IdentityUser> CreateExternalUserAsync(ExternalLoginInfo info)
            {
                await IdentityOptions.SetAsync();
    
                var emailAddress = info.Principal.FindFirstValue(AbpClaimTypes.Email);
                /* Begin TenantId claim check */
                var tenantId = info.Principal.FindFirstValue(AbpClaimTypes.TenantId);
                if (!string.IsNullOrEmpty(tenantId))
                {
                    try
                    {
                        CurrentTenant.Change(Guid.Parse(tenantId));
                    }
                    catch
                    {
                        await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext()
                        {
                            Identity = IdentitySecurityLogIdentityConsts.IdentityExternal,
                            Action = "Unable to parse TenantId: " + tenantId
                        }) ;
                    }
                }
                /* End TenantId claim check */
    
                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;
            }
        }
    

    The code added is between the comments, the rest of the method was pulled from the source. So I look for the AbpClaimTypes.TenantId claim being present, and if it does I attempt to use the CurrentTenant.Change method to change the tenant prior to the call to create the new IdentityUser.

    Once that is done, the user gets created in the correct tenant and everything flows like expected.