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.