Blazor - Securing using ADFS with local DB repository: how/when to hook into SQL - authentication

I have a Blazer Server app which now uses authentication from a local ADFS server. Having identified the user, I now need to load their permissions. We don't think this can be provided via claims from the ADFS server, so want to configure this in the DB, but need to understand how/when to get this information.
Regarding the hook into ADFS, my code is as follows (any suggestions on improvement most welcome)
App.razor
<CascadingAuthenticationState>
<Router AppAssembly="#typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry</h1>
<p>You're not authorized to reach this page.</p>
<p>You may need to log in as a different user.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="#typeof(MainLayout)">
<h1>Sorry</h1>
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
appsettings.Development.json
{
"DetailedErrors": "true",
"ConnectionStrings": {
"MyDB": "Data Source=x.x.x.x;Initial Catalog=xxxxx;user id=me;password=sshhh;Persist Security Info=False;"
},
"Ida": {
"ADFSMetadata": "https://adfs.ourServer.com/FederationMetadata/2007-06/FederationMetadata.xml",
"Wtrealm": "https://localhost:44323/"
}
}
Startup.cs (only showing security related code)
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.WsFederation;
public class Startup
{
public static void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
.....
app.UseAuthentication();
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-Frame-Options", "DENY");
var user = context.User;
if (user?.Identities.HasNoItems(identity => identity.IsAuthenticated) ?? true)
{
await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme).ConfigureAwait(false);
}
if (next != null)
{
await next().ConfigureAwait(false);
}
});
....
}
...
public void ConfigureServices(IServiceCollection services)
{
var wtrealm = this.Configuration.GetSection("Ida:Wtrealm").Value;
var metadataAddress = this.Configuration.GetSection("Ida:ADFSMetadata").Value;
services
.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme;
})
.AddWsFederation(options =>
{
options.Wtrealm = wtrealm ;
options.MetadataAddress = metadataAddress;
options.UseTokenLifetime = false;
})
.AddCookie();
....
}
}
Any suggestions regarding the above code? When the user enters our site (any page), they automatically get pushed to the ADFS server to authenticate. Seems okay, but prevents the user from logging out....
So, from ADFS we get several claims that identify the user, e.g. their UPNname. My thought is to go to the DB and load all the roles/permissions/rights that this user has.
Where in my code should I put such code
The DB is currently used by another application that uses the older "membership" tables. I want to use something a bit more up-to-date, the identity model? I can't risk breaking security for the other application. Should I store security in a new DB?
Any pointers would be most welcome...assume I'm a novice at this.

The usual way to do this is to write a custom attribute provider for ADFS.
Then you can get the roles you want from the DB and they are added to the claims.

After reading a lot around this area, I discovered a very clearly presented PluralSight course that solved it for me:
ASP.NET Core 2 Authentication Playbook: Chris Klug
Specifically, see the chapter:
Doing Claims Transformation
For details, watch the course. In summary:
Create the Entity and Context for pulling your data from the DB.
Register these in the Startup in the normal manner.
Create a ProfileService (that has the logic to read the data from the
Context). I also included a MemoryCache object so I could locally
store the info.
Then create a Claims Transformation class (implementing IClaimsTransformation) and register that as a service.
Add these into your Blazor page's code-behind file as:
[Inject]
protected IClaimsTransformation ClaimsTransformation { get; set; } = null!;
[CascadingParameter]
protected Task AuthenticationStateTask { get; set; }
Consume the data thus:
AuthenticationState authState = await this.AuthenticationStateTask;
ClaimsPrincipal user = authState.User;
ClaimsPrincipal x = await this.ClaimsTransformation.TransformAsync(user);

Related

How do I authorise the Hangfire Dashboard via Microsoft Single Sign-On with Angular 12 and ASP.Net Core 5

My application is an Angular 12 application running on ASP.Net Core 5.
I am currently trying to lock down Hangfire so that it will only work for people with the Admin role.
It uses Microsoft Identity to log in - specifically Single Sign-on, set up in Azure.
public void ConfigureServices(IServiceCollection services)
{
...
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
...
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] {
new HangfireAuthorisationFilter()
},
AppPath = "/"
});
...
app.UseEndpoints(endpoints => {
...
});
app.UseSpa(spa=>{
...
});
}
This works in my dot net core controllers.
All I need to do to get it to work is add the Authorize attribute:
namespace MyAppName.Controllers
{
[Produces("application/json")]
[Route("api/MyRoute")]
[Authorize(Roles="Role1,Role2,Administrator")]
public class MyControllerController: MyBaseApiController
{
...
}
}
But when I want to Authorise in Hangfire, the User object is missing a whole lot of its properties.
Here is the HangfireAuthorisationFilter:
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
// the next line always fails. The User object is set. The Identity object is set
// but there are no claims and the User.Name is null. There are also no roles set.
return httpContext.User.Identity.IsAuthenticated;
}
}
There is, however, cookie information, containing the msal cookie:
How can I pass authentication information into the Hangfire Authorize method? How can I access the role information so that I can lock it down to just the Admin role? Is there a way I can decode the msal cookie server-side?
Assuming you have an AzureAd configuration block that looks like below:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
"TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]",
"ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]"
}
I think a better approach to avoid manual validation of the token is to change your code to the following:
public void ConfigureServices(IServiceCollection services)
{
services.AddHangfire(x =>
{
x.UseSqlServerStorage(sqlServerConnectionString);
});
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration);
services.
.AddAuthentication(AzureADDefaults.AuthenticationScheme)
.AddAzureAD(options => Configuration.Bind("AzureAd", options));
services.AddAuthorization(options =>
{
options.AddPolicy("Hangfire", builder =>
{
builder
.AddAuthenticationSchemes(AzureADDefaults.AuthenticationScheme)
.RequireRole("Admin")
.RequireAuthenticatedUser();
});
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHangfireDashboard("/hangfire", new DashboardOptions()
{
Authorization = Enumerable.Empty<IDashboardAuthorizationFilter>()
})
.RequireAuthorization("Hangfire");
});
}
To break this down, the following changes have been made:
Add authentication for AzureADDefaults.AuthenticationScheme so we can create a policy requiring the "Admin" role.
Add a policy named "Hangfire" that requires the "Admin" role against a user. See the AddAuthorization call.
Instead of calling UseHangfireDashboard we call MapHangfireDashboard inside UseEndpoints and protect the hangfire dashboard endpoint using our "Hangfire" policy through the call to RequireAuthorization("Hangfire")
Removal off the HangfireAuthorisationFilter which is not needed and instead we pass an empty collection of filters in the MapHangfireDashboard call.
The key takeaway is that we are now relying on the security provided by the middleware rather than the implementation of IDashboardAuthorizationFilter which comes with huge risk around the token being invalid and/or a mistake is made in the logic.
Ok I have figured out how to decode the msal cookie to get my list of claims and roles, and authorise successfully with Hangfire
using Hangfire.Dashboard;
using System.IdentityModel.Tokens.Jwt;
namespace MyApp.Filters
{
public class HangfireAuthorisationFilter : IDashboardAuthorizationFilter
{
public HangfireAuthorisationFilter()
{
}
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
var cookies = httpContext.Request.Cookies;
var msalIdToken = cookies["msal.{your app client id goes here}.idtoken"];
var token = new JwtSecurityTokenHandler().ReadJwtToken(msalIdToken);
foreach(var claim in token.Claims)
{
if (claim.Type=="roles" && claim.Value == "Admin")
{
return true;
}
}
return false;
}
}
}

ASP.NET Core enrich IIdentity with custom profile

I am using Azure AD to authorize and authenticate the users.
All users have a profile in the database.
I would like on login to always "merge" the Azure user with my database user.
This is the code I am using to setup authentication in my web api.
public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddBearerAuthentication(this IServiceCollection services,
OpenIdConnectOptions openIdConnectOptions)
{
#if DEBUG
IdentityModelEventSource.ShowPII = true;
#endif
services
.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", o =>
{
o.Authority = openIdConnectOptions.Authority;
o.TokenValidationParameters.ValidIssuer = openIdConnectOptions.ValidIssuer;
o.TokenValidationParameters.ValidAudiences = openIdConnectOptions.ValidAudiences;
});
return services;
}
}
Can someone point me in the right direction?
Right now I am loading the user in all of my controllers, not pretty at all.
Not sure what do you mean by "merge" the user. But if it's just some logic you want to run for every incoming http request, you could just add a custom middleware
app.Use(async (context, next) =>
{
var user = await context.RequestServices
.GetRequiredService<DatabaseContext>()
.Users
.Where(....)
.SingleOrDefaultAsync();
...
await next(context);
});
Alternatively, if you want to couple your code with the authentication process very much, you could use the callback from JwtBearerOptions
.AddJwtBearer("Bearer", o =>
{
...
o.Events.OnTokenValidated = async context =>
{
var user = await context.HttpContext
.RequestServices
.GetRequiredService....
...
};
}
But personally, I think both approaches are bad. Going to the DB to get the user's credentials with every request is bad for performance. Also, it kinda defies the whole point of the JWT, which was designed specifically to not do that. The token should already contain all the claims inside. If it doesn't, I would suggest reconfiguring azure AD, or switch to self-issued tokens.

signInManager.GetExternalLoginInfoAsync() returns Null In Blazor

Im trying to add external login functions to my Sever-side blazor.
so far i could login with a google account and it seems to work great so far.
This is how i setup the authentication for google.
services.AddAuthentication(options => { /* Authentication options */ })
.AddGoogle(options =>
{
// Provide the Google Client ID
options.ClientId = "{MyClientID}";
// Provide the Google Client Secret
options.ClientSecret = "{ClientSecret}";
options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
options.SaveTokens = true;
options.Events.OnCreatingTicket = ctx =>
{
List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();
tokens.Add(new AuthenticationToken()
{
Name = "TicketCreated",
Value = DateTime.UtcNow.ToString()
});
ctx.Properties.StoreTokens(tokens);
return Task.CompletedTask;
};
})
My Problem is i dont know if the claims and additional info's are registered (cause there is no trace of them in my database and i can't retrieve them).
I know that i have to get the external user info via SignInManager.
So in my Blazor component i inject the SignManager like this:
#inject SignInManager<ApplicationUser> signInManager
then i call the ExternalInfo Like this:
var result= await signInManager.GetExternalLoginInfoAsync();
But the result is always null. What do i do wrong? Why is it always null?
A Quick update:
I tested a Razor Page. this works fine on razor pages. so signInManager.GetExternalLoginInfoAsync();
returns null when im calling it from a blazor component.
According to your description, I suggest you could use IHttpContextAccessor instead of using SignInManager to get the user information.
More details, you could refer to below codes:
Add HttpContextAccessor service in ConfigureServices method:
services.AddHttpContextAccessor();
Then use it in component:
#page "/"
#using Microsoft.AspNetCore.Http
#inject IHttpContextAccessor accessor
<h1>Hello, world!</h1>
Welcome to your new app.
<h1>
#username
</h1>
#code{
public string username;
protected override async Task OnInitializedAsync()
{
username = accessor.HttpContext.User.Identity.Name;
}
}
Result:

Blazor Server - how to configure for on-premises ADFS Security?

I have an existing Blazor (Server) app addressing .NET Core 3.1 preview 2.
I need to retrospectively add on-prem ADFS (not Azure) security. I've been trying to follow Microsoft's Authenticate users with WS-Federation in ASP.NET Core and it's stubbornly ignoring the security. The article is of course written for ASP.NET, not Blazor...
What I've done so far is:
public static void ConfigureServices(IServiceCollection services)
{
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddAuthentication()
.AddWsFederation(options =>
{
options.MetadataAddress = "https://adfs.Server.com/FederationMetadata/2007-06/FederationMetadata.xml";
options.Wtrealm = "https://localhost:44323/";
});
services.AddAuthorization();
services.AddRazorPages();
services.AddServerSideBlazor();
....
One thing of concern - the DB currently has tables in it supporting an earlier authentication pattern (membership?) (used by the application we're re-writing). It has the tables [AspNetRoles] [AspNetUserClaims] [AspNetUserLogins] [AspNetUserRoles] and [AspNetUsers]. Will any of this get overwritten?
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder != null)
{
if (optionsBuilder.IsConfigured == false)
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
.AddJsonFile($"appsettings.{Startup.CurrentEnvironment}.json")
.Build();
optionsBuilder
.UseSqlServer(configuration.GetConnectionString("MyDatabase"),
providerOptions => providerOptions.CommandTimeout(60));
}
}
base.OnConfiguring(optionsBuilder);
}
}
In the Configure method, I've added (though I'm not clear on whether I needed to):
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
In the App.razor, I have:
<CascadingAuthenticationState>
<Router AppAssembly="#typeof(Program).Assembly">
<Found Context="routeData">
#*<RouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)" />*#
<AuthorizeRouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)" />>
</Found>
<NotFound>
<LayoutView Layout="#typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
And in one of my razor pages (MyPage.razor), I've got:
#page "/myRoute"
#attribute [Authorize]
When I browse to the page with the Autorize attribute, I get the message:
Not authorized
So it's not calling out to my ADFS server. Shouldn't it just do this automatically - the user shouldn't have to click a "log me in" button.
I've referenced the following NuGet packages:
<PackageReference Include="Microsoft.AspNetCore.Authentication.WsFederation" Version="3.1.0-preview2.19528.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.1.0-preview2.19528.8" />
I have even followed Microsoft's example for Use WS-Federation without ASP.NET Core Identity, but with no luck.
This I an older post, but still pops up first in Google for me so..
I had more or less arrived at the same issue. The OP's post in this thread helped me:
Blazor - Securing using ADFS with local DB repository: how/when to hook into SQL
More specifically, the middleware added to Configure() method made a difference. I ended up with this in my solution:
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
ClaimsPrincipal user = context.User;
if (!user.Identities.Any(x => x.IsAuthenticated))
{
await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme).ConfigureAwait(false);
}
if (next != null)
{
await next().ConfigureAwait(false);
}
});

A single login page for multiple authentication types (including Azure AD) in .NET Core MVC

I have a .NET Core 2.2 MVC web-application. And I've added two authentication types/providers there:
Login/password with local users database (custom thing, without .NET Core Identity)
Azure AD
My goal is to have a login page at /account/login where users can choose between these two authentications and log-in with either of those. So every time an unauthenticated user would open any page (from a controller with [Authorize] attrubite), he would get redirected to /account/login page, which has a login/password web-form with its own submit button, and additionally a Office 365 login link/button.
Just to make it clear - I don't want a custom Microsoft sign-in / Azure AD page. I only want unauthenticated users to get my login page first, from where they can either log-in using my web-form or click on Office 365 login and get to Microsoft sign-in page.
Now, the authentication part is done and seems to work fine, I can log-in with either of authentications, but my plan with redirecting unauthenticated user to /account/login failed. What happens instead is that user is being redirected to Microsoft sign-in page right away. So it looks like Azure AD authentication has a higher priority somehow.
Here's my implementation.
Startup.cs:
// ...
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.None;
});
// the presence of CookieAuthenticationDefaults.AuthenticationScheme doesn't seem to influence anything
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
// makes no difference either
//services.AddAuthentication(
// options =>
// {
// options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// }
//)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, // and it also can be omitted here
options =>
{
options.LoginPath = "/Account/Login";
options.ExpireTimeSpan = TimeSpan.FromDays(45);
})
.AddAzureAD(options => _configuration.Bind("AzureAD", options));
services.AddAuthorization(options =>
{
// as the default policy, it applies to all [Authorize] controllers
options.DefaultPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme,
AzureADDefaults.AuthenticationScheme
)
.RequireAuthenticatedUser() // a simple policy that only requires a user to be authenticated
.Build();
});
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
options.Authority = options.Authority + "/v2.0/";
options.TokenValidationParameters.ValidateIssuer = false;
});
services.AddMvc(options =>
{
// it is my understanding that there is no need create a policy here
// and perform "options.Filters.Add(new AuthorizeFilter(policy))",
// because the default policy is already added and controllers have explicit [Authorize] attribute
// [...] well, actually I tried that too, but it didn't change anything
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory
)
{
// ...
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
});
}
AccountController.cs:
[Authorize]
[Route("account")]
public class AccountController : Controller
{
// ...
// that is where "Office 365 login" link leads
[HttpGet("login-ad")]
[AllowAnonymous]
public IActionResult LoginAD(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
else
{
if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "/"; }
return Challenge(
new AuthenticationProperties { RedirectUri = returnUrl },
AzureADDefaults.AuthenticationScheme
);
}
}
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
// that is where login/password web-form submits to
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
if (ModelState.IsValid)
{
await _usersManager.SignIn(
model.Login,
model.Password
);
// ...
return LocalRedirect(returnUrl);
}
ViewData["ReturnUrl"] = returnUrl;
return View(model);
}
// ...
}
HomeController.cs:
[Authorize]
public class HomeController : Controller
{
// ...
public IActionResult Index()
{
return View();
}
// ...
}
So, opening any page by an unauthenticated user results in immediate redirect to Microsoft sign-in page. And in order to get to /account/login (to have a chance to log-in using another authentication) users have to open that URL explicitly.
If I remove AzureADDefaults.AuthenticationScheme from default policy, then all unauthenticated requests will now get redirected to /account/login - exactly what I want - but naturally Azure AD authentication doesn't work anymore:
These redirects tell me that after successful authentication at Microsoft sign-in page it returns user back to /account/login, but user is still not authenticated on my website.
I can of course add [AllowAnonymous] to Index action of HomeController and return redirect to /account/login for unauthenticated users, but that obviously would only work for / route.
I have a feeling that I don't understand some things about AddAuthentication(), schemes and policies, thus apparently I did something wrong in Startup.cs. Can you please help me to understand what's wrong there? Or maybe there is some other way to achieve what I want?
Updated answer
I decided to clone the example project mentioned here in the quickstart-v2-aspnet-core-webapp documentation and see if I could reproduce your error.
After cloning the project I added two NuGet packages.
Microsoft.AspNetCore.Identity 2.2.0
Microsoft.AspNetCore.Identity.EntityFrameworkCore 2.2.0
Then added the database context that extends IdentityContext.
ApplicationDbContext.cs
In Startup.cs
Registered Identity
Registered the database context and provided connection string
In AppSettings.json
Configured TenantID and ClientID
Ran the application.
At this point, the app launches and redirects me to Account/Login, where I choose Sign in via Microsoft account.
Now, I can obviously see there is something wrong. It wouldn't authenticate the user.
Turns out:
The extension method .AddAzureAd() actually cannot be used in combination with other authentication methods. See this issue on github.
But luckily the workaround is fairly simple. Just switch out .AddAzureAd() for .AddOpenIdConnect() and change your AppSettings' AzureAd section to:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Authority": "https://login.microsoftonline.com/{tenantID}/v2.0/",
"TenantId": "{tenantID}",
"ClientId": "{clientID}",
"CallbackPath": "/signin-oidc"
},
Now I can log in perfectly fine with AzureAD and local user accounts as well.
For your convenience, I uploaded the complete example project to my GitHub page.