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

I am using Per-Tenant Data with identity but the AddLoginAsync from Identity library won't work #926

Open
paburgos opened this issue Jan 7, 2025 · 11 comments
Labels

Comments

@paburgos
Copy link

paburgos commented Jan 7, 2025

Hello,

I am wondering if you can suggest a fix for this issue:

I am using Per-Tenant Data with identity, And as the documentation describes I can see the TenantID added as a primary key to the AspNetUserLogins table. However, when I try to add a login:

result = await UserManager.AddLoginAsync(user, externalLoginInfo);

I get this exception:

'System.InvalidOperationException': Unable to track an entity of type 'IdentityUserLogin<string>' because its primary key property 'TenantId' is null.

@kin3tik
Copy link

kin3tik commented Jan 16, 2025

I'm running into the same issue. @AndrewTriesToCode do you have any advice? 🙏

@AndrewTriesToCode
Copy link
Contributor

AndrewTriesToCode commented Jan 22, 2025

Hi, that particular entity is a little different from the others and I'm not sure why.

First thing I recommend to try is to set the TenantNotSetMode for your context to Override to basically force it.

I will look into this further -- let me know if that setting helps.

@kin3tik
Copy link

kin3tik commented Jan 23, 2025

Hi Andrew, thank you for the suggestion and I appreciate all the work you've put into this project.

I've tried overriding the TenantNotSetMode in my db context that derives from MultiTenantIdentityDbContext, but I'm still receiving the same error as described by the OP. Is this the correct approach? I'm not sure.

public class ApplicationDbContext : MultiTenantIdentityDbContext<ApplicationUser>
{ 
    private CustomTenantInfo? TenantInfo { get; set; }

    public ApplicationDbContext(IMultiTenantContextAccessor<CustomTenantInfo> multiTenantContextAccessor, DbContextOptions<ApplicationDbContext> options)
        : base(multiTenantContextAccessor, options)
    {
        TenantInfo = multiTenantContextAccessor.MultiTenantContext.TenantInfo;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        this.TenantNotSetMode = TenantNotSetMode.Overwrite;
      
        base.OnConfiguring(optionsBuilder);
    }
}

@AndrewTriesToCode
Copy link
Contributor

That would have been too easy huh?

Hm, are you using the default Identity UI? Do you have the external login page scaffolded out by any chance and if so can you copy paste it here?

@kin3tik
Copy link

kin3tik commented Jan 24, 2025

Yes I'm just using the default scaffolded identity code for Areas\Identity\Pages\Account\ExternalLogin.cshtml.cs - I'll paste it below.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using KailoMedical.IdentityProvider.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;

namespace KailoMedical.IdentityProvider.Areas.Identity.Pages.Account
{
    [AllowAnonymous]
    public class ExternalLoginModel : PageModel
    {
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly IUserStore<ApplicationUser> _userStore;
        private readonly IUserEmailStore<ApplicationUser> _emailStore;
        private readonly IEmailSender _emailSender;
        private readonly ILogger<ExternalLoginModel> _logger;

        public ExternalLoginModel(
            SignInManager<ApplicationUser> signInManager,
            UserManager<ApplicationUser> userManager,
            IUserStore<ApplicationUser> userStore,
            ILogger<ExternalLoginModel> logger,
            IEmailSender emailSender)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _userStore = userStore;
            _emailStore = GetEmailStore();
            _logger = logger;
            _emailSender = emailSender;
        }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [BindProperty]
        public InputModel Input { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public string ProviderDisplayName { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public string ReturnUrl { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [TempData]
        public string ErrorMessage { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public class InputModel
        {
            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [EmailAddress]
            public string Email { get; set; }
        }
        
        public IActionResult OnGet() => RedirectToPage("./Login");

        public IActionResult OnPost(string provider, string returnUrl = null)
        {
            // Request a redirect to the external login provider.
            var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
            var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            return new ChallengeResult(provider, properties);
        }

        public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            if (remoteError != null)
            {
                ErrorMessage = $"Error from external provider: {remoteError}";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }
            var info = await _signInManager.GetExternalLoginInfoAsync();
            if (info == null)
            {
                ErrorMessage = "Error loading external login information.";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }

            // Sign in the user with this external login provider if the user already has a login.
            var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
            if (result.Succeeded)
            {
                _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
            if (result.IsLockedOut)
            {
                return RedirectToPage("./Lockout");
            }
            else
            {
                var info2 = await _signInManager.GetExternalLoginInfoAsync();
                // If the user does not have an account, then ask the user to create an account.
                ReturnUrl = returnUrl;
                ProviderDisplayName = info.ProviderDisplayName;
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
                {
                    Input = new InputModel
                    {
                        Email = info.Principal.FindFirstValue(ClaimTypes.Email)
                    };
                }
                return Page();
            }
        }

        public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
        {
            returnUrl = returnUrl ?? Url.Content("~/");
            // Get the information about the user from the external login provider
            var info = await _signInManager.GetExternalLoginInfoAsync();
            if (info == null)
            {
                ErrorMessage = "Error loading external login information during confirmation.";
                return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
            }

            if (ModelState.IsValid)
            {
                var user = CreateUser();

                await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
                await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

                var result = await _userManager.CreateAsync(user);
                if (result.Succeeded)
                {
                    result = await _userManager.AddLoginAsync(user, info);
                    if (result.Succeeded)
                    {
                        _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                        var userId = await _userManager.GetUserIdAsync(user);
                        var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                        code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                        var callbackUrl = Url.Page(
                            "/Account/ConfirmEmail",
                            pageHandler: null,
                            values: new { area = "Identity", userId = userId, code = code },
                            protocol: Request.Scheme);

                        await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                            $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                        // If account confirmation is required, we need to show the link if we don't have a real email sender
                        if (_userManager.Options.SignIn.RequireConfirmedAccount)
                        {
                            return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                        }

                        await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
                        return LocalRedirect(returnUrl);
                    }
                }
                foreach (var error in result.Errors)
                {
                    ModelState.AddModelError(string.Empty, error.Description);
                }
            }

            ProviderDisplayName = info.ProviderDisplayName;
            ReturnUrl = returnUrl;
            return Page();
        }

        private ApplicationUser CreateUser()
        {
            try
            {
                return Activator.CreateInstance<ApplicationUser>();
            }
            catch
            {
                throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
                    $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
                    $"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml");
            }
        }

        private IUserEmailStore<ApplicationUser> GetEmailStore()
        {
            if (!_userManager.SupportsUserEmail)
            {
                throw new NotSupportedException("The default UI requires a user store with email support.");
            }
            return (IUserEmailStore<ApplicationUser>)_userStore;
        }
    }
}

@AndrewTriesToCode
Copy link
Contributor

AndrewTriesToCode commented Jan 24, 2025

Ok I think I have a solution for you.

Create a class:

public class TenantDefaultValueGenerator : ValueGenerator<string>
{
    public override string? Next(EntityEntry entry)
    {
        return "__tenant__";
    }

    public override bool GeneratesTemporaryValues => false;
}

Then in your context setup do something similar to this:

public class MyDbContext...
{

  protected override OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.Entity<IdentityUserLogin<string>>().Property("TenantId").HasValueGenerator<TenantDefaultValueGenerator>();

    base.OnModelCreating(modelBuilder)
  }
}

And set TenantMismatchMode to overwrite instead of TenantNotSet Mode.

This will have the field default to __tenant__ which will satisfy EFCore then Finbuckle will plug in the current tenant when you save changes.

@kin3tik
Copy link

kin3tik commented Jan 24, 2025

Hi Andrew,

Thank you very much for the potential solution, however I feel like I'm missing something obvious. I don't seem to have access to HasValueGenerator - as far as I can tell it should be included within the Microsoft.EntityFrameworkCore package which I have/am referencing.

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<IdentityUserLogin<string>>()
        .HasValueGenerator<TenantDefaultValueGenerator>();
}
'EntityTypeBuilder<IdentityUserLogin<string>>' does not contain a definition for 'HasValueGenerator' and no accessible extension method 'HasValueGenerator' accepting a first argument of type 'EntityTypeBuilder<IdentityUserLogin<string>>' could be found (are you missing a using directive or an assembly reference?)

As a side note, should the HasValueGenerator<TenantValueGenerator>() call within OnModelCreating be TenantDefaultValueGenerator instead of TenantValueGenerator?

EDIT:

I can call HasValueGenerator by specifying a property like so:

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<IdentityUserLogin<string>>()
        .Property<string>("TenantId")
        .HasValueGenerator<TenantDefaultValueGenerator>();
}

However, if I do that then I receive the following error:

InvalidOperationException: The entity type 'IdentityUserLogin<string>' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'

EDIT 2:

If I flesh this out a bit more and add the keys like so

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<IdentityUserLogin<string>>()
        .Property<string>("TenantId")
        .IsRequired()
        .HasValueGenerator<TenantDefaultValueGenerator>();

    builder.Entity<IdentityUserLogin<string>>()
        .HasKey("LoginProvider", "ProviderKey", "TenantId");
}

Then I run into

InvalidOperationException: The entity type 'IdentityUserRole<string>' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'.

As someone not very familiar with non-tyical EF usage I'm not sure if I'm digging the right way through this rabbit hole 😅

@AndrewTriesToCode
Copy link
Contributor

AndrewTriesToCode commented Jan 24, 2025

Hi, you figured it out before I could answer. For the primary Key thing -- I didn't specify it but I call base.OnModelCreating(...) so it sets the primary key within Finbuckle! You shouldn't need to set the primary key yourself.

I have edited the above with the corrections.

And you are doing great, this is a great learning experience for you and I both. I figured this out a few years ago and had to to figure it out now. Maybe it'll stick this time. I will probably build this right into the library for the next release.

@kin3tik
Copy link

kin3tik commented Jan 24, 2025

Ahh. I left out the call to base.OnModelCreating() in my code because I noticed you hadn't explicitly included it and I didn't think to try adding it 😅

Good news and bad news - I can now get past that issue but run into this immediately afterwards:

InvalidOperationException: The property 'IdentityUserLogin<string>.TenantId' cannot be assigned a temporary value. Temporary values can only be assigned to properties configured to use store-generated values.

in

... Areas.Identity.Pages.Account.ExternalLoginModel.OnPostConfirmationAsync(string returnUrl) in ExternalLogin.cshtml.cs

512.    result = await _userManager.AddLoginAsync(user, info);

@AndrewTriesToCode
Copy link
Contributor

Haha ok in the generator change that property to false instead of true. Sorry I’m not on my computer so it’s been hit or miss…

@kin3tik
Copy link

kin3tik commented Jan 24, 2025

🤦 Oh I should read the code I paste more carefully I didn't even notice that property. Changing to false appears to have worked!

Now when I log in it seems to log me back out immediately, but I believe thats a seperate issue somewhere that's probably my fault.

Thank you again Andrew, appreciate the guidance.

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

No branches or pull requests

3 participants