I recently came across a project where there was a slightly different twist for the site’s authentication needs. For this project the company had three different authentication scenarios that needed to be covered.
- Log in via a primary Azure AD tenant. This Azure AD instance was set up with application roles and the users were set up to pass the role as a claim to indicate if they were an Editor or an Administrator. Upon validation the user is mapped to a virtual user with Editor and Administrator permissions.
- Log in via a secondary Azure AD tenant. This tenant was set up to send back AD Groups as Role Claims. The AD groups were created with a known naming convention. Upon validating the claim the user is mapped to a virtual user that has access to certain areas of the site.
- Log in via forms login. The user’s credentials are stored in the Episerver database.
The solution to allowing multiple AD tenants was based on the Episerver Developers guide to Integrate Azure AD using OpenID Connect
Login Flow
Episerver Code Overview
The Episerver solution utilizes OWIN to establish the various log-in flows. In order to be able to switch between Azure AD tenants during the authentication process a new log in page was created. This allows the end user to either log in via Forms Authentication or choose an Azure AD Tenant to login to. The majority of the heavy lifting is done through the Startup file to register the authentication methods and the login controller to determine which AD instance to send the user to.
Startup.cs
The file registers the OWIN component and processes the authentication responses returned from the Azure AD tenants. This code registers all three login mechanisms with OWIN.
The registration and the claims processing can be seen in the following methods of the startup.cs file:
- Configuration – Registers the three login processes.
- GetStandardAuthNotification – A callback function registered in the Configuration and is called after Azure AD authentication has been performed
- SecurityTokenValidated – Interrogates the claims returned from Azure AD and adds one or more claims that are responsible for mapping the user to and existing roles inside of Episerver
Configuration()
public void Configuration(IAppBuilder app)
{
// Add CMS ASP.NET Identity
app.AddCmsAspNetIdentity<ApplicationUser>();
// Enable cookie authentication, used to store the claims between requests
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());
//// Setting up SSO with the Azure AD Tenant 1
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions(AzureTenant_1_SsoId)
{
ClientId = AzureTenant_1_ClientId,
Authority = String.Format(CultureInfo.InvariantCulture, AzureTenant_1_Instance, AzureTenant_1_Domain),
PostLogoutRedirectUri = logoutPath,
TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
RoleClaimType = ClaimTypes.Role
},
Notifications = GetStandardAuthNotification(AzureTenant_1_ReturnPath)
});
//// Setting up SSO with the Azure AD Tenant 2
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions(AzureTenant_2_SsoId)
{
ClientId = AzureTenant_2_ClientId,
Authority = String.Format(CultureInfo.InvariantCulture, AzureTenant_2_Instance, AzureTenant_2_Domain),
PostLogoutRedirectUri = logoutPath,
TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
RoleClaimType = ClaimTypes.Role
},
Notifications = GetStandardAuthNotification(AzureTenant_2_ReturnPath)
});
// Use cookie to store information for the signed in user
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
SlidingExpiration = true,
ExpireTimeSpan = TimeSpan.FromMinutes(2880),
LoginPath = new PathString(loginPath),
LogoutPath = new PathString(logoutPath),
Provider = new CookieAuthenticationProvider
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager<ApplicationUser>, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(480),
regenerateIdentity: (manager, user) => manager.GenerateUserIdentityAsync(user)),
OnApplyRedirect = (context => context.Response.Redirect(context.RedirectUri)),
OnResponseSignOut = (context => context.Response.Redirect(loginPath))
}
});
// Add CMS integration for ASP.NET Identity
app.SetupCustomAspNetIdentity<ApplicationUser>();
app.UseStageMarker(PipelineStage.Authenticate);
// If the application throws an antiforgery token exception like “AntiForgeryToken: A Claim of Type NameIdentifier or IdentityProvider Was Not Present on Provided ClaimsIdentity”
AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
}
GetStandardAuthNotification()
In the GetStandardAuthNotification function we are interrogating the claim returned from Azure AD and then adding a new claim that maps the user to an existing role. For the first Azure Tenant we are looking for a claim of “CMSMarketingEditors” and if that claim exists we add the user to the Episerver role of “Tenant-1-Editor”. For the second Azure Tenant we have a more advanced set of criteria. For this we interrogate each claim to see if it matches a known pattern. All editors were put in AD groups such as CMS_Editor_123, or CMS_Editor_768. If the pattern is matched, then we add a claim for the user to put them in a known Episerver Group that is tied to a section of the site that they should have access to.
private OpenIdConnectAuthenticationNotifications GetStandardAuthNotification(string returnUrl)
{
return new OpenIdConnectAuthenticationNotifications
{
AuthenticationFailed = context =>
{
context.HandleResponse();
context.Response.Write(context.Exception.Message);
return Task.FromResult(0);
},
RedirectToIdentityProvider = context =>
{
// Here you can change the return uri based on multisite
HandleMultiSiteReturnUrl(context, returnUrl);
// To avoid a redirect loop to the federation server send 403
// when user is authenticated but does not have access
if (context.OwinContext.Response.StatusCode == 401 &&
context.OwinContext.Authentication.User.Identity.IsAuthenticated)
{
context.OwinContext.Response.StatusCode = 403;
context.HandleResponse();
}
return Task.FromResult(0);
},
SecurityTokenValidated = async (ctx) =>
{
var redirectUri = new Uri(ctx.AuthenticationTicket.Properties.RedirectUri,
UriKind.RelativeOrAbsolute);
if (redirectUri.IsAbsoluteUri)
{
ctx.AuthenticationTicket.Properties.RedirectUri = redirectUri.PathAndQuery;
}
// Interrogate claims to see if they are have the CMSMarketingEditors Azure AD application role
var Tenant_1_ClaimMatched = ctx.AuthenticationTicket
.Identity.Claims.Where(x => string.Equals("CMSMarketingEditors", x.Value, StringComparison.CurrentCultureIgnoreCase))
.FirstOrDefault();
if (Tenant_1_ClaimMatched != null)
{
ctx.AuthenticationTicket.Identity.AddClaim(new Claim(ClaimTypes.Role, "Tenant-1-Editor", ClaimValueTypes.String, AzureTenant_1_SsoId));
}
// Interrogate claims to see if they are an group editor based on Azure AD Groups. The AD groups use a known pattern
var azureAD_GroupRegex = new Regex(AzureAD_GroupPattern);
var claimMatches = ctx.AuthenticationTicket.Identity.Claims.Where(x => azureAD_GroupRegex .IsMatch(x.Value));
if (claimMatches.Any())
{
//Adding a claim to add the user to the special Admin group for the Azure AD 2 tenant users
ctx.AuthenticationTicket.Identity.AddClaim(new Claim(ClaimTypes.Role, "Tenant-2-Users", ClaimValueTypes.String, AzureTenant_2_SsoId));
foreach (var groupClaim in claimMatches)
{
var adGroupNumber = new string(groupClaim.Value.TakeWhile(Char.IsDigit).ToArray());
var episerverGroup = string.Format(adGroupFormat, adGroupNumber.ToString());
ctx.AuthenticationTicket.Identity.AddClaim(new Claim(ClaimTypes.Role, episerverGroup, ClaimValueTypes.String, AzureTenant_2_SsoId));
}
}
// Storing role as SSO in claims dictionary. Useful when logging out user.
ctx.AuthenticationTicket.Identity.AddClaim(new Claim(ClaimTypes.Role, "SSO"));
ctx.AuthenticationTicket.Properties.ExpiresUtc = DateTime.UtcNow.AddHours(48);
ctx.AuthenticationTicket.Properties.IsPersistent = true;
//await ServiceLocator.Current.GetInstance<AzureGraphService>().CreateRoleClaimsAsync(ctx.AuthenticationTicket.Identity);
//Sync user and the roles to EPiServer in the background
await ServiceLocator.Current.GetInstance<ISynchronizingUserService>()
.SynchronizeAsync(ctx.AuthenticationTicket.Identity);
}
};
}
Custom Login Page
In order to present the end users with the ability to log in via all three paths a new login page was created. The html was based on the current login form with additional buttons that posted back to the login controller passing the context of the tenant to log in to as well as the return URL. The key here is to map the tenant context to the names Azure AD instances that were registered in the configuration process. For the sake of this article we will only be focusing on the code related to the Azure AD authentication.
Here is the code for these two buttons:
@using (Html.BeginForm("SsoLogin", "CustomLogin", new { ReturnUrl = Request.QueryString["ReturnUrl"], tenantContext = AzureTenant_1_SsoId}))
{
@Html.AntiForgeryToken()
<ol class="clearfix">
<li>
<input type="submit" value="Log in with Azure AD Tenant 1" id="btnAd1" class="epi-button-child-item" />
</li>
</ol>
}
@using (Html.BeginForm("SsoLogin", "CustomLogin", new { ReturnUrl = Request.QueryString["ReturnUrl"], tenantContext = AzureTenant_2_SsoId}))
{
@Html.AntiForgeryToken()
<ol class="clearfix">
<li>
<input type="submit" value="Log in with Azure AD Tenant 2" id="btnAd2" class="epi-button-child-item" />
</li>
</ol>
}
The Login Controller provides the necessary actions for logging in and signing out of the site. This controller consist of the following methods:
- Index (Get) – returns the Login Page.
- Index (Post) – used to validate users against the Episerver DB via the forms authentication.
- SsoLogin – this method takes in a querystring parameter to determine if the user should be authenticated via the Tenant 1 or the Tenant 2.
- SignOut – determines which method the user has logged in with and signs the user out of that context.
The login buttons post back to the SSO method of the Login controller passing in the tenant context. The tenant context is then passed into the Owin and the user is passed off to the Azure AD login page for the correct Azure AD tenant.
public ActionResult SsoLogin(string tenantContext, string returnUrl)
{
HttpContext.GetOwinContext().Authentication.Challenge(new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }, tenantContext);
return View("/Views/Login/Index.cshtml", new LoginViewModel());
}
Upon successful authentication the user is passed back to OWIN and the GetStandardAuthNotification callback method is called to validate the claims.
And… The circle is now complete.