Finbuckle.MultiTenant Routing Strategy and IdentityServer4 Tenant login page redirection

Viewed 1091

We target to build multi-tenant identity server4 application that can authenticate multiple tenants. We've been trying to integrate Finbuckle Mutitenancy with IdentityServer4 to achieve this. A variation of routing strategy mentioned in the docs has been implemented to resolve the tenant on every request. When a client requests the connect/authorize endpoint of IdentityServer (without the tenant identifier in the route URL), the login URL, that the identity server redirects to, misses the tenant identifier. The custom multitenant strategy gets executed successfully and sets the tenantInfo in the expected way: http://localhost/Identity/Account/Login is used instead of http://localhost/tenant1/Account/Login.

We have provided our own IMultiTenantStrategy implementation to retrieve the tenant identifier from route first, if not, IdentityServer's request parameters (custom parameter, "tenant" added from the client that requests the token.). The MultiTenantMiddleware succeeds in retrieving the tenant identifier using the custom strategy and sets the required tenantInfo, store, strategy etc. But then the connect/authorize endpoint redirects to http://localhost/Identity/Account/Login skipping the tenant template.

However, if we use the FallbackStrategy, which defines static tenant identifier to use if all of the other strategies fail, then the authorize endpoint redirects the browser to http://localhost/tenant1/Account/Login, provided tenant1 is used in the FallbackStrategy's static identifier. Here's the code snippet:

IdentityServer's ConfigureServices:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<ApplicationDbContext>();
        services.AddDefaultIdentity<IdentityUser>()
            .AddEntityFrameworkStores<ApplicationDbContext>();

        services.AddControllersWithViews().AddRazorRuntimeCompilation();
        services.AddRazorPages(options =>
        {
            // Since we are using the route multitenant strategy we must add the
            // route parameter to the Pages conventions used by Identity.
            options.Conventions.AddAreaFolderRouteModelConvention("Identity", "/Account", model =>
            {
                foreach (var selector in model.Selectors)
                {
                    selector.AttributeRouteModel.Template =
                        AttributeRouteModel.CombineTemplates("{__tenant__}", selector.AttributeRouteModel.Template);
                }
            });
        });

        services.DecorateService<LinkGenerator, AmbientValueLinkGenerator>(new List<string> { "__tenant__" });

        services.AddMultiTenant()
                .WithStrategy<CustomMultiTenantStrategy>(ServiceLifetime.Transient, "__tenant__") // Looks at route first then specific to idserver4's request
                //.WithFallbackStrategy("tenant1") // If added, always redirects to tenant1's login page.
                .WithConfigurationStore()
                .WithRemoteAuthentication()
                .WithPerTenantOptions<AuthenticationOptions>((options, tenantInfo) =>
                {
                    // Allow each tenant to have a different default challenge scheme.
                    if (tenantInfo.Items.TryGetValue("ChallengeScheme", out object challengeScheme))
                    {
                        options.DefaultChallengeScheme = (string)challengeScheme;
                        // options.DefaultSignOutScheme = (string)challengeScheme;
                    }
                })
                .WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantInfo) =>
                {
                    // Since we are using the route strategy configure each tenant
                    // to have a different cookie name and adjust the paths.
                    options.Cookie.Path = $"/{tenantInfo.Identifier}";
                    options.Cookie.Name = $"{tenantInfo.Id}_authentication";
                    options.LoginPath = $"{options.Cookie.Path}{options.LoginPath}";
                    options.LogoutPath = $"{options.Cookie.Path}{options.LogoutPath}";
                });

        // configure identity server with in-memory stores, keys, clients and scopes
        services.AddIdentityServer()
            .AddDeveloperSigningCredential()
            .AddInMemoryPersistedGrants()
            .AddInMemoryIdentityResources(ResourceStore.GetIdentityResources())
            .AddInMemoryApiResources(ResourceStore.GetApiResources())
            .AddInMemoryClients(ClientStore.Get())
            .AddAspNetIdentity<IdentityUser>()
            .AddProfileService<CustomProfileService>();
    }

IdentityServer's Configure method:

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseDatabaseErrorPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }

        app.UseStaticFiles();
        app.UseRouting();
        app.UseMultiTenant();
        app.UseIdentityServer();
        app.UseAuthorization();

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

Custom Multitenant Strategy:

public class CustomMultiTenantStrategy : IMultiTenantStrategy
{
    internal readonly string tenantParam;
    private readonly IIdentityServerInteractionService _interactionService;
    public CustomMultiTenantStrategy(string tenantParam, IIdentityServerInteractionService interactionService)
    {
        if (string.IsNullOrWhiteSpace(tenantParam))
        {
            throw new ArgumentException($"\"{nameof(tenantParam)}\" must not be null or whitespace", nameof(tenantParam));
        }
        this.tenantParam = tenantParam;
        this._interactionService = interactionService;
    }

    public async Task<string> GetIdentifierAsync(object context)
    {
        if (!(context is HttpContext))
            throw new MultiTenantException(null,
                new ArgumentException($"\"{nameof(context)}\" type must be of type HttpContext", nameof(context)));

        var httpContext = context as HttpContext;

        object identifier = null;
        httpContext.Request.RouteValues.TryGetValue(tenantParam, out identifier);

        // Fallback to read from the request query params
        if (identifier == null)
        {
            httpContext.Request.Query.TryGetValue("tenant", out StringValues tenant);

            if (tenant.Count > 0)
            {
                identifier = tenant.ToString();
            }
            else // authorize/connect request would go in here
            {
                if (httpContext.Request.Query.ContainsKey("returnUrl"))
                {
                    var returnUrl = httpContext.Request.Query["returnUrl"];

                    var authContext = await this._interactionService.GetAuthorizationContextAsync(returnUrl);

                    identifier = authContext.Parameters["tenant"];
                }
            }
        }

        return await Task.FromResult(identifier as string);
    }
 }

MVC Client's Configure Services:

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = "Cookies";
            options.DefaultChallengeScheme = "oidc";
        })
            .AddCookie("Cookies")
            .AddOpenIdConnect("oidc", options =>
            {
                options.Authority = "http://localhost";
                options.RequireHttpsMetadata = false;
                options.ClientId = "test-ui";
                options.ClientSecret = "XXXXX";
                options.ResponseType = "code id_token";
                options.Scope.Add("custom-profile");
                options.Scope.Add("offline_access");
                options.SaveTokens = true;

                options.Events = new OpenIdConnectEvents
                {
                    OnRedirectToIdentityProvider = (ctx) =>
                    {
                        var tenant = ctx.HttpContext.Request.IsHttps ? "tenant1" : "tenant2"; // Temporary way to swtich between two tenants - the same mvc client is used by different tenants, in prod, it would be identified based on subdomain.
                        ctx.ProtocolMessage.Parameters.Add("tenant", tenant );
                        ctx.ProtocolMessage.AcrValues = $"tenant:{tenant}";

                        return Task.CompletedTask;
                    }
                };
            });

        services.AddControllersWithViews();

If we remove .WithFallbackStrategy("tenant1") from ConfigureServices, then the identity server would redirect to http://localhost/Identity/Account/Login resulting in a 404. We would want the tenant name to be dynamically set based on the parameter that the MVC client passes via request parameters. Can anyone who is familiar with Finbuckle.MultiTenant shed some light on this?

0 Answers
Related