A client of ours asked us a while back to implement Azure B2C authentication for their Episerver implementation. The client is running 15+ websites, each on their own TLD for global presence. They are running their website all over the world, from The Netherlands, Germany, Brazil to China. Note, the websites are not running on a separate subdomain, which is implemented out-of-the-box (and allowed) using Azure B2C. In this blog I will describe the hurdles we had to take to get Azure B2C working on multiple TLD websites.
The Problem
Azure B2C authentication, unlike Azure Active Directory allows you to connect to any customer using their existing social accounts or personal emails. Azure Active Directory B2C supports Facebook, Microsoft Accounts, LinkedIn, and many others, or you can add your own. Azure B2C only supports authentication on one domain, or multiple of it’s subdomains. So if you have multiple TLD websites running, which is usually the case in a large Episerver implementation this will not work out-of-the-box. One of the problems you bump into is when you select the homepage of another website in Episerver CMS, then it redirects to the new domain and tries to authenticate again, this will fail because of the TLD limitation. The same happens when you navigate to another TLD website from the configurated one.
The solution
The solution is when you navigate to another TLD domain, you need to supply another ClientId thus resulting in creating multiple Azure B2C endpoints, one for each TLD domain. When you supply a ClientId associated with the correct TLD domain in the OnRedirectToIdentityProvider
method in your Owin Startup class you are able to use multiple TLD domains with Azure B2C. Now you only have to map the requested domain with the correct ClientId. I used a custom configuration section for this so the client can map the client identifiers to the correct domain.
The implementation
Below is a sample of my custom Owin startup class, in the OnRedirectToIdentityProvider
method a function named HandleMultiSiteReturnUrl
is called and the correct ClientId is requested from the configuration. You also have to make sure to whitelist all Client Identifiers in the ValidAudiences
property on the TokenValidationParameters
.
OwinStartup.cs
[assembly: OwinStartup(typeof(Startup))]
namespace Website.Web.Implementation.Initialization
{
public class Startup
{
private const string LogoutPath = "/logout";
public static HttpConfiguration HttpConfiguration { get; private set; }
public static ClientIdConfigurationSection ClientIdConfigurationSection = ClientIdConfigurationSection.GetSection();
public void Configuration(IAppBuilder app)
{
var cookieSettings = new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = "/account/logon",
ExpireTimeSpan = TimeSpan.FromMinutes(45),
Provider = new CookieAuthenticationProvider
{
OnApplyRedirect = ctx =>
{
ctx.Response.Redirect(ctx.RedirectUri);
},
},
SlidingExpiration = true,
};
app.SetDefaultSignInAsAuthenticationType(cookieSettings.AuthenticationType);
app.UseCookieAuthentication(cookieSettings);
app.UseOpenIdConnectAuthentication(CreateOptionsFromPolicy(ConfigurationManager.AppSettings["ida:B2CSignupSigninPolicyId"]));
app.UseStageMarker(PipelineStage.Authenticate);
AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.Email;
app.Map(
LogoutPath,
map =>
{
map.Run(ctx =>
{
ctx.Authentication.SignOut();
return Task.FromResult(0);
});
});
}
private OpenIdConnectAuthenticationOptions CreateOptionsFromPolicy(string policy) => new OpenIdConnectAuthenticationOptions
{
MetadataAddress = string.Format(CultureInfo.CurrentCulture, new Uri(ConfigurationManager.AppSettings["ida:B2CInstanceUrl"]).ToString(), ConfigurationManager.AppSettings["ida:B2CTenant"], policy),
AuthenticationType = policy,
AuthenticationMode = AuthenticationMode.Active,
ClientId = ConfigurationManager.AppSettings["ida:B2CClientId"],
RedirectUri = new Uri(ConfigurationManager.AppSettings["ida:RedirectUri"]).ToString(),
PostLogoutRedirectUri = new Uri(ConfigurationManager.AppSettings["ida:RedirectUri"]).ToString(),
Notifications = new OpenIdConnectAuthenticationNotifications
{
RedirectToIdentityProvider = OnRedirectToIdentityProvider,
AuthenticationFailed = OnAuthenticationFailed,
SecurityTokenValidated = SecurityTokenValidated,
},
Scope = OpenIdConnectScopes.OpenId,
ResponseType = OpenIdConnectResponseTypes.IdToken,
TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
ValidAudiences = ClientIdConfigurationSection.GetClientIds(), // Retrieve all clientIds from the configuration who are valid.
},
SignInAsAuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
UseTokenLifetime = false,
};
private Task OnRedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
{
HandleMultiSiteReturnUrl(notification); // Handle the return url and supply the correct client Id associated to the doamin.
...
return Task.FromResult(0);
}
private Task OnAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
{
...
}
private Task SecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
{
ServiceLocator.Current.GetInstance<ISynchronizingUserService>()
.SynchronizeAsync(notification.AuthenticationTicket.Identity); // Synchronise the incoming identity with Episerver
return Task.FromResult(true);
}
private void HandleMultiSiteReturnUrl(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
{
var currentUrl = HttpContext.Current.Request.Url;
context.ProtocolMessage.RedirectUri = new UriBuilder(
currentUrl.Scheme,
currentUrl.Host,
currentUrl.Port).ToString(); // Assign the redirect url, this is done dynamically. Other than the one above in the OpenIdConnectAuthenticationOptions class
context.ProtocolMessage.ClientId = ClientIdConfigurationSection.GetClientId(context.ProtocolMessage.RedirectUri); // Supply the correct ClientId associated with the domain.
}
}
}
WebsiteConfigurationSectionGroup.cs
using System.Configuration;
namespace Website.Configuration
{
public class WebsiteConfigurationSectionGroup : ConfigurationSectionGroup
{
}
}
ClientIdConfigurationSection.cs
using System.Collections.Generic;
using System.Configuration;
namespace Website.Configuration.B2C
{
public class ClientIdConfigurationSection : ConfigurationSection
{
private const string ConfigurationPath = "websiteConfigurationSectionGroup/b2cClientIdConfigurationSection";
[ConfigurationProperty("clientIdConfigurations", IsRequired = true)]
public ClientIdConfigurationElementCollection ClientIdConfigurations
{
get { return (ClientIdConfigurationElementCollection)this["clientIdConfigurations"]; }
set { this["clientIdConfigurations"] = value; }
}
public static ClientIdConfigurationSection GetSection()
{
return (ClientIdConfigurationSection)
ConfigurationManager.GetSection(ConfigurationPath);
}
public static ClientIdConfigurationSection GetSection(string configurationPath)
{
return (ClientIdConfigurationSection)
ConfigurationManager.GetSection(configurationPath);
}
public string[] GetClientIds()
{
var clientIds = new List<string>();
foreach (ClientIdConfigurationElement clientIdConfigurationElement in ClientIdConfigurations)
{
clientIds.Add(clientIdConfigurationElement.ClientId);
}
return clientIds.ToArray();
}
public string GetClientId(string redirectUri)
{
foreach (ClientIdConfigurationElement clientIdConfigurationElement in ClientIdConfigurations)
{
if (redirectUri.Contains(clientIdConfigurationElement.Host))
{
return clientIdConfigurationElement.ClientId;
}
}
return null;
}
}
}
ClientIdConfigurationElementCollection.cs
using System;
using System.Configuration;
namespace Website.Configuration.B2C
{
public class ClientIdConfigurationElementCollection : ConfigurationElementCollection
{
public void Add(ClientIdConfigurationElement clientIdConfigurationElement)
{
this.BaseAdd(clientIdConfigurationElement);
}
protected override ConfigurationElement CreateNewElement()
{
var clientIdConfigurationElement = new ClientIdConfigurationElement();
return clientIdConfigurationElement;
}
protected override object GetElementKey(ConfigurationElement element)
{
if (!(element is ClientIdConfigurationElement))
{
throw new ArgumentException("The supplied configuration element is not of the correct type.");
}
var clientIdConfigurationElement = (ClientIdConfigurationElement)element;
return clientIdConfigurationElement.Host;
}
}
}
ClientIdConfigurationElement.cs
using System.Configuration;
namespace Website.Configuration.B2C
{
public class ClientIdConfigurationElement : ConfigurationElement
{
[ConfigurationProperty("host", IsRequired = true)]
public string Host
{
get { return (string)this["host"]; }
set { this["host"] = value; }
}
[ConfigurationProperty("clientId", IsRequired = true)]
public string ClientId
{
get { return (string)this["clientId"]; }
set { this["clientId"] = value; }
}
}
}
Sample web.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<sectionGroup name="websiteConfigurationSectionGroup" type="Website.Configuration.WebsiteConfigurationSectionGroup, Website, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null">
<section name="b2cClientIdConfigurationSection" type="Website.Configuration.B2C.ClientIdConfigurationSection, Website, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</sectionGroup>
</configSections>
<websiteConfigurationSectionGroup>
<b2cClientIdConfigurationSection>
<clientIdConfigurations>
<add host="website.com" clientId="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
<add host="website.nl" clientId="yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" />
</clientIdConfigurations>
</b2cClientIdConfigurationSection>
</websiteConfigurationSectionGroup>
</configuration>