Finbuckle / Finbuckle.MultiTenant

Finbuckle.MultiTenant is an open-source multitenancy middleware library for .NET. It enables tenant resolution, per-tenant app behavior, and per-tenant data isolation.
https://www.finbuckle.com/multitenant
Apache License 2.0
1.26k stars 256 forks source link

ArgumentException: Options.ClientId must be provided (Parameter 'ClientId') #803

Closed marceloataide closed 2 months ago

marceloataide commented 3 months ago

I am getting this error

An unhandled exception occurred while processing the request. ArgumentException: Options.ClientId must be provided (Parameter 'ClientId') Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.Validate()

TargetInvocationException: Exception has been thrown by the target of an invocation. System.Reflection.MethodInvoker.Invoke(object obj, IntPtr* args, BindingFlags invokeAttr)

MultiTenantException: Exception in Finbuckle.MultiTenant.Strategies.RemoteAuthenticationCallbackStrategy.GetIdentifierAsync. Finbuckle.MultiTenant.Strategies.MultiTenantStrategyWrapper.GetIdentifierAsync(object context)

I am using dotnet core 7

I will use keycloak with multiple tenant..

using Finbuckle.MultiTenant;
using Contrato;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using System.Configuration;
using Site.Helpers;
using SiteEvento.services;
using SiteEvento.Services;
using System.Security.Claims;
using System.Security.Cryptography;
using Site;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json");
builder.Services.AddControllersWithViews();
builder.Services.AddHttpContextAccessor();
builder.Services.AddControllersWithViews();
IdentityModelEventSource.ShowPII = true; 

builder.Services.AddMultiTenant<TenantInfoExtendida>()
           .WithHostStrategy()
           //   .WithEFCoreStore<TenantAdminDbContext, MultiTenantInfo>()
           .WithPerTenantAuthentication()
           .WithConfigurationStore()
           .WithPerTenantOptions<CookieAuthenticationOptions>((options, tenant) =>
           {
               options.Cookie.Name = $".Auth{tenant.Id}";
           })
           .WithPerTenantOptions<OpenIdConnectOptions>((options, tenant) =>
           {
               options.Scope.Add("openid");
               options.Scope.Add("profile");

               options.SaveTokens = true;
               options.GetClaimsFromUserInfoEndpoint = true;
               options.TokenValidationParameters.NameClaimType = "name";
               options.ResponseType = OpenIdConnectResponseType.Code;

               options.Authority = tenant.OpenIdAuthority;
               options.ClientId = tenant.OpenIdClientId;
               options.ClientSecret = tenant.OpenIdClientSecret;
           });
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect();
var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseRouting();

app.UseMultiTenant();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

var scopeServices = app.Services.CreateScope().ServiceProvider;
var store = scopeServices.GetRequiredService<IMultiTenantStore<TenantInfoExtendida>>();
var tenants = await store.GetAllAsync();
foreach (var tenant in await store.GetAllAsync())
{
    // await using var db = new ApplicationDbContext(tenant);
    //  await db.Database.MigrateAsync();
}
app.Run();
AndrewTriesToCode commented 3 months ago

Hi, the OpenIdConnect authentication will always validate the options. Even though you are doing per-tenant options the base options need to satisfy validation.

Take a look at this older sample, you'll see I set dummy values knowing they will be overridden:

https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/a3cae0af149b13791b3083353cb24d8684b94af5/samples/ASP.NET%20Core%203/PerTenantAuthenticationSample/Startup.cs#L31-L36

I should add something to the docs about this. Cheers.

marceloataide commented 3 months ago

I dont know how logon on keycloak server like this image keycloak-admin-login-form-2

AndrewTriesToCode commented 3 months ago

The OpenID connect authority for the tenant will send them there. Did you try a “dummy” default value for the OpenID connect settings so that you wouldn’t get the validation message?

marceloataide commented 2 months ago

I dont know whats is wrong.. always send to https://localhost:7208/Account/Login

using Finbuckle.MultiTenant; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using IdentitySample.Data; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System.Security.Claims; using IdentitySample;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container. builder.Services.AddDbContext(); builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = true) .AddEntityFrameworkStores(); builder.Services.AddRazorPages();

// Add MultiTenant

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddOpenIdConnect(options => { options.ClientId = " tenant "; // Needed for validation, will be overwritten per-tenant. options.Authority = " tenant "; // Needed for validation, will be overwritten per-tenant. options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; //Keycloak server options.ClientSecret = " tenant "; options.MetadataAddress = " tenant "; //// //Require keycloak to use SSL options.RequireHttpsMetadata = false; options.GetClaimsFromUserInfoEndpoint = true; options.Scope.Add("openid"); options.Scope.Add("profile"); //Save the token options.SaveTokens = true; //Token response type, will sometimes need to be changed to IdToken, depending on config. options.ResponseType = OpenIdConnectResponseType.Code; //SameSite is needed for Chrome/Firefox, as they will give http error 500 back, if not set to unspecified. options.NonceCookie.SameSite = SameSiteMode.Unspecified; options.CorrelationCookie.SameSite = SameSiteMode.Unspecified;

 options.TokenValidationParameters = new TokenValidationParameters
 {
     NameClaimType = "name",
     RoleClaimType = ClaimTypes.Role,
     ValidateIssuer = true
 };

}) .AddCookie(cookie => { //Sets the cookie name and maxage, so the cookie is invalidated. cookie.Cookie.Name = "keycloak.cookie"; cookie.Cookie.MaxAge = TimeSpan.FromMinutes(60); cookie.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; cookie.SlidingExpiration = true;

});

builder.Services.AddMultiTenant() .WithBasePathStrategy(options => options.RebaseAspNetCorePathBase = true) .WithConfigurationStore() .WithRouteStrategy() .WithPerTenantAuthentication() .WithPerTenantOptions((o, tenantInfo) => { o.Cookie.Name = "SignInCookie - " + tenantInfo.Id; });

var app = builder.Build();

// Apply migrations if needed var store = app.Services.GetRequiredService<IMultiTenantStore>(); foreach(var tenant in await store.GetAllAsync()) { await using var db = new ApplicationDbContext(tenant); await db.Database.MigrateAsync(); }

// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseMigrationsEndPoint(); } else { app.UseExceptionHandler("/Error"); app.UseHsts(); }

app.UseHttpsRedirection(); app.UseStaticFiles();

app.UseMultiTenant(); app.UseRouting();

app.UseAuthentication(); app.UseAuthorization();

app.MapRazorPages();

app.UseEndpoints(endpoints => { endpoints.MapControllerRoute("default", "{tenant}/{controller=Home}/{action=Index}"); });

app.Run();

AndrewTriesToCode commented 2 months ago

I don’t see anything obvious. It’s going to the login url because in your AddAuthentication you set the default scheme to cookies so any unauthorized request will redirect via cookie settings.

I recommend you try to get it working WITHOUT multitenant just to make sure these problems are not due to multitenant. Once you get it working normally see if you still see this behavior when you add multitenant.

marceloataide commented 2 months ago

Andrew with your tips worked well. I would like share this solution whats worked well with keycloak. I am testing yet, but now I can logon on the server. Thanks for your help

using Finbuckle.MultiTenant; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using IdentitySample.Data; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using System.Security.Claims; using IdentitySample; using Microsoft.AspNetCore.Authentication.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container. builder.Services.AddDbContext(); builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = true) .AddEntityFrameworkStores(); builder.Services.AddRazorPages(); // Add MultiTenant builder.Services.AddAuthentication(options => { //Sets cookie authentication scheme options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; })

.AddCookie(cookie => { //Sets the cookie name and maxage, so the cookie is invalidated. cookie.Cookie.Name = "keycloak.cookie"; cookie.Cookie.MaxAge = TimeSpan.FromMinutes(60); cookie.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; cookie.SlidingExpiration = true;

}) .AddOpenIdConnect(options => { //Use default signin scheme options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; //Keycloak server options.Authority = "tenant"; //Keycloak client ID options.ClientId = "tenant"; //Keycloak client secret options.ClientSecret = "tenant"; //Keycloak .wellknown config origin to fetch config // options.MetadataAddress = "tenant";
////Require keycloak to use SSL options.RequireHttpsMetadata = false; options.GetClaimsFromUserInfoEndpoint = true; options.Scope.Add("openid"); options.Scope.Add("profile"); //Save the token options.SaveTokens = true; //Token response type, will sometimes need to be changed to IdToken, depending on config. options.ResponseType = OpenIdConnectResponseType.Code; //SameSite is needed for Chrome/Firefox, as they will give http error 500 back, if not set to unspecified. options.NonceCookie.SameSite = SameSiteMode.Unspecified; options.CorrelationCookie.SameSite = SameSiteMode.Unspecified; options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = "name", RoleClaimType = ClaimTypes.Role, ValidateIssuer = true };

});

//builder.Services.AddAuthorization(options => //{ // //Create policy with more than one claim // options.AddPolicy("users", policy => // policy.RequireAssertion(context => // context.User.HasClaim(c => // (c.Value == "user") || (c.Value == "admin")))); // //Create policy with only one claim // options.AddPolicy("admins", policy => // policy.RequireClaim(ClaimTypes.Role, "admin")); // //Create a policy with a claim that doesn't exist or you are unauthorized to // options.AddPolicy("noaccess", policy => // policy.RequireClaim(ClaimTypes.Role, "noaccess")); //});

builder.Services.AddMultiTenant() .WithBasePathStrategy(options => options.RebaseAspNetCorePathBase = true) .WithConfigurationStore() .WithRouteStrategy() .WithPerTenantAuthentication();

var app = builder.Build();

var store = app.Services.GetRequiredService<IMultiTenantStore>(); foreach(var tenant in await store.GetAllAsync()) { await using var db = new ApplicationDbContext(tenant); await db.Database.MigrateAsync(); }

// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseMigrationsEndPoint(); } else { app.UseExceptionHandler("/Error"); app.UseHsts(); }

app.UseHttpsRedirection(); app.UseStaticFiles();

app.UseMultiTenant(); app.UseRouting();

app.UseAuthentication(); app.UseAuthorization();

app.MapRazorPages();

app.UseEndpoints(endpoints => { endpoints.MapControllerRoute("default", "{tenant}/{controller=Home}/{action=Index}"); });

app.Run();

AndrewTriesToCode commented 2 months ago

Hi, I’m glad you got it working and thanks for sharing your solution for others!