Here is how I eventually solved the the MS Login infinite redirects with the tenant per subdomain scheme problem. (Trying to come up with a better name for the problem. )
Provide a SigninRedirect
controller action that accepts a returnUrl
parameter that we must validate to avoid being an Open Redirect.
Example URL with returnUrl set to companyA.example.com/foo&bar=1
:
https://signin.example.com/signin-redirect?returnUrl=companyA.example.com%2Ffoo%26bar%3D1
[Route("")]
public class SigninController : Controller
{
private readonly IMediator _mediator;
private readonly IConfiguration _configuration;
public SigninController(IMediator mediator, IConfiguration configuration)
{
_mediator = mediator;
_configuration = configuration;
}
[Authorize]
[HttpGet("/signin-redirect")]
public IActionResult SigninRedirect(string returnUrl)
{
string redirect;
if (!string.IsNullOrEmpty(returnUrl) && IsValidSubdomainUrl(returnUrl))
{
redirect = returnUrl;
}
else
{
var appHost = _configuration.GetValue<string>("General:ApplicationHost");
var home = new UriBuilder("https", appHost).Uri;
return Redirect(home.AbsoluteUri);
}
return Redirect(redirect);
}
/// <summary>
/// Avoid Open Redirect Vulnerability
/// https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
/// </summary>
/// <param name="returnUrl"></param>
/// <returns></returns>
private bool IsValidSubdomainUrl(string returnUrl)
{
var appHost = _configuration.GetValue<string>("General:ApplicationHost");
var isValid = Uri.IsWellFormedUriString(returnUrl, UriKind.Absolute) &&
Uri.TryCreate(returnUrl, UriKind.Absolute, out var uri) &&
uri?.Host.EndsWith(appHost) == true;
return isValid;
}
}
In ConfigureServices(IServiceCollection services) set cookies to be shared across all the subdomains and configure an OnRedirectToIdentityProvider
event to redirect to your signin URL when the user is not yet authenticated:
var cookieDomain = _configuration.GetValue<string>("General:CookieDomain");
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromDays(3);
options.Cookie.Domain = cookieDomain;
options.Cookie.Path = "/";
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
_configuration.Bind("AzureAd", options);
options.Events ??= new OpenIdConnectEvents();
options.Events.OnRedirectToIdentityProvider += context => {
var originalRequestUri = context.HttpContext.Request.GetUri();
var signInHost = _configuration.GetValue<string>("General:SignInHost");
var signInPath = _configuration.GetValue<string>("General:SignInUrl");
if (!originalRequestUri.Host.Equals(signInHost, StringComparison.InvariantCultureIgnoreCase))
{
var signInUrl = QueryHelpers.AddQueryString(signInPath, "returnUrl", originalRequestUri.AbsoluteUri);
// When on a subdomain and not authorized, then redirect to
// our signin URL.
context.Response.Redirect(signInUrl);
// Let Microsoft.Identity.Web know that we already handled
// this redirect
context.HandleResponse();
}
return Task.CompletedTask;
};
},
cookieOptions =>
{
cookieOptions.Cookie.Domain = cookieDomain;
cookieOptions.Cookie.Path = "/";
cookieOptions.Cookie.SameSite = SameSiteMode.Lax;
})
.EnableTokenAcquisitionToCallDownstreamApi(new string[] { "user.read" })
.AddDistributedTokenCaches();
services.ConfigureApplicationCookie(options =>
{
options.Cookie.Domain = cookieDomain;
options.Cookie.Name = ".AspNet.SharedCookie";
options.Cookie.Path = "/";
options.Cookie.SameSite = SameSiteMode.Lax;
});
Configuration defined:
"General:CookieDomain": ".example.com",
"General:ApplicationHost": "example.com",
"General:SignInHost": "signin.example.com",
"General:SignInUrl": "https://signin.example.com/signin-redirect"
In addition, you will need the usual AzureAd
configuration section from the Microsoft Docs for Azure AD.
"AzureAd:Instance": "https://login.microsoftonline.com/",
"AzureAd:Domain": "...",
"AzureAd:TenantId": "common",
"AzureAd:ClientId": "...",
"AzureAd:ClientSecret": "...",
"AzureAd:CallbackPath": "/signin-oidc",
"AzureAd:SignedOutCallbackPath": "/signout-callback-oidc",
