I am using Per-Tenant Data with identity but the AddLoginAsync from Identity library won't work
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.
I'm running into the same issue. @AndrewTriesToCode do you have any advice? 🙏
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.
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);
}
}
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?
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;
}
}
}
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.
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 😅
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.
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);
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…
🤦 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.
This issue has been labeled inactive because it has been open 30 days with no activity. This will be closed in 7 days without further activity.
edit: No this will not be closed--sorry about that!
I just released a change to remove the tenant from the user login entity -- turned out to cause a lot of problems so hopefully this will help you.
This issue has been labeled inactive because it has been open 180 days with no activity. Please consider closing this issue if no further action is needed.