3
votes

I'm developing multi-tenant ASP.NET MVC application using Finbuckle.Multitenant and IdentityServer4 (using standard classes and controllers from their tutorials). My app uses route strategy for tenants (https://host/tenant1/controller/action) and I use separate cookies for each tenant (cookie named auth.tenant1, auth.tenant2... etc.) Everything works fine unless I specify custom Path for auth cookie. If all of them has Path=/ everything is ok. BUT when I set Path=/tenant1 to cookie named auth.tenant1 and the same pattern for every other tenants I have a circular redirects after passing consent screen. When I click "yes" on consent screen I got 302 challenge redirect from IdentityServer middleware on client side. It redirects me back to Consent screen. After each "yes" I'm returning to consent. However it happens only during authentication process. If I open new tab and head to https://host/tenant1 I won't be redirected and will be successfully authenticated. Googled for answers for days but haven't found any solutions. Please help me!

Here is my client's Startup.cs:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();

        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

        services.AddMultiTenant().WithInMemoryStore(Configuration.GetSection("MultiTenant:InMemoryStore"))
            .WithRouteStrategy(MapRoutes)
            .WithRemoteAuthentication()
            .WithPerTenantOptions<AuthenticationOptions>((options, tenantContext) =>
            {
                // Allow each tenant to have a different default challenge scheme.
                if (tenantContext.Items.TryGetValue("ChallengeScheme", out object challengeScheme))
                {
                    options.DefaultChallengeScheme = (string)challengeScheme;
                }
            })
            .WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantContext) =>
            {
                options.Cookie.Name += tenantContext.Identifier;
                options.Cookie.Path = "/" + tenantContext.Identifier;
                options.LoginPath = "/" + tenantContext.Identifier + "/Home/Login";
            });

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
            {
                o.Cookie.Name = "auth.";
            })
        .AddOpenIdConnect("oidc", options =>
        {
            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.Authority = "https://localhost:5000";
            options.RequireHttpsMetadata = true;

            options.ClientId = "mvc";
            options.SaveTokens = true;

            options.ClientSecret = "secret";
            //Hybrid protocols (OpenId + OAuth)
            options.ResponseType = OpenIdConnectResponseType.CodeIdToken;

            options.GetClaimsFromUserInfoEndpoint = true;
            //ask to allow access to testApi
            options.Scope.Add("testApi");
            //allows requesting refresh tokens for long lived API access
            options.Scope.Add("offline_access");

            options.Events = new OpenIdConnectEvents()
            {
                OnRedirectToIdentityProvider = ctx =>
                {
                    var tenant = ctx.HttpContext.GetMultiTenantContext()?.TenantInfo?.Identifier;
                    ctx.ProtocolMessage.AcrValues = $"tenant:{tenant}";
                    return Task.FromResult(0);
                }
            };
        });
    }

    private void MapRoutes(IRouteBuilder router)
    {
        router.MapRoute("Default", "{__tenant__=tenant1}/{controller=Home}/{action=Index}/{id?}");
    }

Here is my client configuration on IdentityServer's side (appsettings.json):

{
  "clientId": "mvc",
  "clientName": "MVC Client",
  "allowedGrantTypes": [ "hybrid", "client_credentials" ],
  "clientSecrets": [
    { "value": "K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=" } // Sha256("secret")
  ],
  "redirectUris": [ "https://localhost:5002/signin-oidc" ],
  "postLogoutRedirectUris": [ "https://localhost:5002/signout-callback-oidc" ],
  "allowedScopes": [ "openid", "profile", "testApi" ],
  "allowOfflineAccess": true
},

My IdentityServer4 config:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();

        var identityServerBuilder = services.AddIdentityServer()
            .AddDeveloperSigningCredential();

        if (_config.GetSection("AppSettings:UseDummyAuthentication").Get<bool>())
        {
            identityServerBuilder
                .AddInMemoryIdentityResources(_config.GetSection("IdentityResources"))
                .AddInMemoryApiResources(_config.GetSection("ApiResources"))
                .AddInMemoryClients(_config.GetSection("Clients"))
                .AddTestUsers(_config.GetSection("TestUsers"));
        }

        services.AddAuthentication();
    }

Please help me, guys! How can I make Path=/tenant1 scheme work without redirects on consent screen???

1

1 Answers

3
votes

Ok, I figured out what's wrong and post answer to those who will encounter the same problem. The problem is that when you change Path of cookie IdentityServer's middleware can't find it because it hosted on https://host/signin-oidc/. What you need to handle this is https://host/tenant1/signin-oidc for each of your tenant on client and add all those urls to clients redirectUris. To achieve it your multitenant configuration should look like this

            services.AddMultiTenant().WithInMemoryStore(Configuration.GetSection("MultiTenant:InMemoryStore"))
            .WithRouteStrategy(MapRoutes)
            .WithRemoteAuthentication()
            .WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantContext) =>
            {
                options.Cookie.Name = $"auth.{tenantContext.Identifier}";
                options.Cookie.Path = "/" + tenantContext.Identifier;
                options.LoginPath = "/" + tenantContext.Identifier + "/Home/Login";
            })
            .WithPerTenantOptions<OpenIdConnectOptions>((opt, ctx) =>
            {
                opt.CallbackPath = "/" + ctx.Identifier + "/signin-oidc";
            });

Whole Startup.cs

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();

        JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

        services.AddMultiTenant().WithInMemoryStore(Configuration.GetSection("MultiTenant:InMemoryStore"))
            .WithRouteStrategy(MapRoutes)
            .WithRemoteAuthentication()
            .WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantContext) =>
            {
                options.Cookie.Name = $"auth.{tenantContext.Identifier}";
                options.Cookie.Path = "/" + tenantContext.Identifier;
                options.LoginPath = "/" + tenantContext.Identifier + "/Home/Login";
            })
            .WithPerTenantOptions<OpenIdConnectOptions>((opt, ctx) =>
            {
                opt.CallbackPath = "/" + ctx.Identifier + "/signin-oidc";
            }); ;

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, o =>
            {
                o.Cookie.Name = "auth.";
                o.Cookie.IsEssential = true;
            })
        .AddOpenIdConnect("oidc", options =>
        {
            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.Authority = "https://localhost:5000";

            options.ClientId = "mvc";
            options.SaveTokens = true;

            options.ClientSecret = "secret";
            //Hybrid protocols (OpenId + OAuth)
            options.ResponseType = OpenIdConnectResponseType.CodeIdToken;

            options.GetClaimsFromUserInfoEndpoint = true;
            //ask to allow access to testApi
            options.Scope.Add("testApi");
            //allows requesting refresh tokens for long lived API access
            options.Scope.Add("offline_access");

            options.Events = new OpenIdConnectEvents()
            {
                OnRedirectToIdentityProvider = ctx =>
                {
                    var tenant = ctx.HttpContext.GetMultiTenantContext()?.TenantInfo?.Identifier;
                    ctx.ProtocolMessage.AcrValues = $"tenant:{tenant}";
                    return Task.FromResult(0);
                }
            };
        });
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseBrowserLink();
            app.UseDeveloperExceptionPage();
        }
        else
        {
            var errorPage = Configuration.GetValue<string>("ErrorPage");
            app.UseExceptionHandler(errorPage);
            app.UseHsts();
        }
        app.UseHttpsRedirection();
        app.UseStaticFiles();
        app.UseMultiTenant();
        app.UseAuthentication();
        app.UseMvc(MapRoutes);
    }

    private void MapRoutes(IRouteBuilder router)
    {
        router.MapRoute("Default", "{__tenant__=tenant1}/{controller=Home}/{action=Index}/{id?}");
    }

Don't forget to register all tenants urls in IdentityServer4 Client.RedirecUris

"redirectUris": [ "https://localhost:5002/tenant1/signin-oidc", "https://localhost:5002/tenant2/signin-oidc", "https://localhost:5002/tenant3/signin-oidc" ]