0

I am in the process of building out a Saas-based multi-tenant framework. (e.g. tenant1.mydomain.com, tenant2.mydomain.com). Tenants are database-driven and not 4known during app startup. The Auth server is auth.mydomain.com and is currently running IdentityServer4 & AspNetIdentity.

Tenants have will have the option to bring their own AzureAD provider and will supply their own ClientID and TenantID.

I already have the MVC Client sending over the tenant properties like such

        services.AddAuthentication(o =>
        {
            o.DefaultScheme = "Cookies";
            o.DefaultChallengeScheme = "oidc";
        })
        .AddCookie("Cookies")
        .AddOpenIdConnect("oidc", o =>
        {
            o.SignInScheme = "Cookies";
            o.Authority = Configuration.GetSection("MicroFlux")["OidcAuthority"];
            o.RequireHttpsMetadata = true;
            o.GetClaimsFromUserInfoEndpoint = true;
            o.ClientId = Configuration.GetSection("OidcClientSettings")["ClientId"];
            o.ClientSecret = Configuration.GetSection("OidcClientSettings")["ClientSecret"];
            o.ResponseType = Configuration.GetSection("OidcClientSettings")["ResponseType"];
            //options.SaveTokens = true;
            o.Scope.Add("roles");
            o.Events.OnRedirectToIdentityProvider = n =>
            {
                SetTenantRedirectToIdentityProps(n);
                return Task.CompletedTask;
            };
        });

    public void SetTenantRedirectToIdentityProps(RedirectContext n)
    {

        var tenant = n.HttpContext.GetTenant();

        if (tenant != null && tenant.TenantSsoProviderType != TenantSSOProviderType.MicroFluxIdentity)
        {
            //set props to auth server that reflects tenant's settings

            n.ProtocolMessage.Parameters.Add("tenantid", tenant.Id.ToString());
            n.ProtocolMessage.Parameters.Add("tenantname", tenant.Name);

            switch (tenant.TenantSsoProviderType)
            {
                case TenantSSOProviderType.AzureAd:
                    n.ProtocolMessage.Parameters.Add("preferred_provider", "aad_" + tenant.Name);
                    n.ProtocolMessage.Parameters.Add("aad_authority", $"https://login.microsoftonline.com/{tenant.SSO_AzureAd_TenantId}/v2.0");
                    n.ProtocolMessage.Parameters.Add("aad_clientid", tenant.SSO_AzureAd_ClientId);
                    break;
                case TenantSSOProviderType.Okta:
                    n.ProtocolMessage.Parameters.Add("preferred_provider", "okta");
                    //todo:add okta next
                    break;
            }

        }
    }

This part works well as the redirect to my identity server (auth.mydomain.com) can access the authority url, client id at runtime (mostly written from this How can I set the Authority on OpenIdConnect middleware options dynamically?).

My relevant auth startup.cs code has this:

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = "oidc";
        })
        .AddCookie("Cookies")
        .AddOpenIdConnect("aad", "Login with Azure AD", o =>
        {
            o.Authority = $"https://login.microsoftonline.com/";
            o.TokenValidationParameters = new TokenValidationParameters {ValidateIssuer = false};
            o.ClientId = "<<set dynamically>>";
            o.CallbackPath = "/signin-oidc";
        });

        services.AddSingleton<TenantProvider>();
        services.AddSingleton<IOptionsMonitor<OpenIdConnectOptions>, OpenIdConnectOptionsProvider>();
        services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, OpenIdConnectOptionsInitializer>();

Here's the tenant provider:

public class TenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantProvider(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor;

    public bool UsingExternalProvider { get; set; } = false;
    public string Authority { get; set; }
    public string SSO_AzureAd_ClientId { get; set; }
    public string SignInScheme { get; set; }


    public string GetCurrentTenant()
    {
        var name = "default";
        var cntx = _httpContextAccessor.HttpContext;

        if (cntx != null && !string.IsNullOrEmpty(cntx.Request.Query["returnUrl"]))
        {

            var decodedQueryString = WebUtility.UrlDecode(cntx.Request.Query["returnUrl"]);
            var dict = QueryHelpers.ParseQuery(decodedQueryString);

            UsingExternalProvider = true;

            if (dict.ContainsKey("preferred_provider") && dict["preferred_provider"].ToString().StartsWith("aad_"))
            {
                Authority = dict["aad_authority"].ToString();
                SSO_AzureAd_ClientId = dict["aad_clientid"];
                SignInScheme = dict["tenantname"].ToString().ToLower();
            }

            name = dict["tenantname"];

        }

        return name ?? "default";
    }
}

public class OpenIdConnectOptionsInitializer : IConfigureNamedOptions<OpenIdConnectOptions>
{
    private readonly IDataProtectionProvider _dataProtectionProvider;
    private readonly TenantProvider _tenantProvider;

    public OpenIdConnectOptionsInitializer(
        IDataProtectionProvider dataProtectionProvider,
        TenantProvider tenantProvider)
    {
        _dataProtectionProvider = dataProtectionProvider;
        _tenantProvider = tenantProvider;
    }

    public void Configure(string name, OpenIdConnectOptions options)
    {
        if (!string.Equals(name, OpenIdConnectDefaults.AuthenticationScheme, StringComparison.CurrentCultureIgnoreCase))
        {
            //return;
        }

        var tenant = _tenantProvider.GetCurrentTenant();

        // Create a tenant-specific data protection provider to ensure
        // encrypted states can't be read/decrypted by the other tenants.
        options.DataProtectionProvider = _dataProtectionProvider.CreateProtector(tenant);

        // Other tenant-specific options like options.Authority can be registered here.
        options.Authority = _tenantProvider.Authority;
        options.ClientId = _tenantProvider.SSO_AzureAd_ClientId;
        options.SignInScheme = _tenantProvider.SignInScheme;

    }

    public void Configure(OpenIdConnectOptions options)
        => Debug.Fail("This infrastructure method shouldn't be called.");
}

Current State: Everything above seems to work in that I am able to redirect to the tenant's AzureAD instance, complete a login, and then receive back the ID Token from the dynamically-determined authority.

However, upon arriving back from AzureAD to my /signin-oidc endpoint, I receive the following error:

Exception: Unable to unprotect the message.State. Unknown location

I have referenced several posts about this (e.g. Multiple IdentityServer Federation : Error Unable to unprotect the message.State) and the accepted resolution was to have unique return urls for different providers. But I am confused as to what to do from here as I have a single provider that I want to modify based on the request to authenticate from the downstream MVC client.

Side question: since I am getting back id_token from the dynamic AzureAD provider, should I just implement my own login logic and manually log in the user and skip the /signin-oidc endpoint all together? There's so much conflicting information out there; hoping I am close.

Solo812
  • 401
  • 1
  • 8
  • 19

1 Answers1

0

You can handle it in another way which is more simple. You can define your tenants as an ApiResource in IdentityServer. when you are redirecting your user for authentication, you should specify your tenant using scope.

In IdentityServer you should implement all possible OAuth services like Azure. Then during user authentication, user will select its way.

Mehrdad
  • 1,523
  • 9
  • 23
  • Sounds interesting and simpler if I could get it to work. Is there an example you could direct me to for reference? I'm interested in seeing how I can still use a Hybrid flow against an Api Resource so that the user's browser is redirected to AzureAD to complete their sign in, and then return with the Id_token that I can then use to construct the claims principal in my auth.mydomain.com world. Thanks. – Solo812 Mar 30 '20 at 12:28
  • There is an open source project which does a lot of required operations, but not exactly this scenario. [skoruba](https://github.com/skoruba/IdentityServer4.Admin). – Mehrdad Mar 30 '20 at 12:58