Integrating Sitefinity with Azure AD B2C for Front End User Authentication

Sitefinity CMS supports out-of-the-box integration with multiple external identity providers like Windows Authentication, ADFS, Facebook and Google. But the support for the emerging cloud-based identity management solution, Azure Active Directory B2C, is not included out-of-the-box. Reason being is the policy framework of Azure B2C which is not supported by Sitefinity.
Whenever a request is sent to Azure B2C, it looks for the policy (eg: sign-in, sign-up, edit profile etc.) in order to redirect the user to the appropriate page. Sitefinity’s OWIN Middleware does not support changing policies. Sitefinity reverts it back to the default policy (in our case it is sign-in policy) if changed to a different policy (like edit profile, reset password etc.), making it hard to reach edit profile or reset password pages. We can achieve this by having the Sitefinity’s OWIN Middleware for Sign In requests (so that front end users can have access to the backend content) and switching to custom middleware for other requests like Edit Profile, Reset Password etc. (for which Sitefinity backend access is not required).
A custom external Identity Provider should be registered for Azure B2C in Sitefinity, which can be done by creating a new AuthenticationProviderElement by navigating to Advanced Settings -> Authentication -> STS -> AuthenticationProviderElement -> Create New,

Once the AuthenticationProviderElement is created, parameters required to connect and authenticate via Azure B2C can be created under that. But the parameters created under AuthenticationProviderElement will be initialized only after our OWIN Middleware is initialized, hence we might not be able to use them in code. So the preferred way would be having the parameters in Application Settings Config.

<add key="ida:Issuer" value="https://login.microsoftonline.com/NTTGEHA.onmicrosoft.com/v2.0"/>
<add key="ida:Tenant" value="NTTGEHA.onmicrosoft.com"/>
<add key="ida:ClientId" value="b74bbd8b-d377-4f69-af51-597f787e2dd1"/>
<add key="ida:AadInstance" value="https://login.microsoftonline.com/tfp/{0}/{1}/v2.0/.well-known/openid-configuration"/>
<add key="ida:RedirectUri" value="http://localhost:60876/Sitefinity/Authenticate/OpenID/signin-custom"/>
<add key="ida:ProfileRedirectUri" value="http://localhost:60876/Home/EditProfileRedirect"/>
<add key="ida:PostLoginRedirectUri" value="http://localhost:60876/"/>
<add key="ida:PostLogoutRedirectUri" value="http://localhost:60876/"/>
<add key="ida:SignUpSignInPolicyId" value="B2C_1_SignUpIn"/>
<add key="ida:EditProfilePolicyId" value="B2C_1_SignProfileEdit"/>
<add key="ida:ResetPasswordPolicyId" value="B2C_1_SignPWReset"/>
<add key="ida:ResponseType" value="id_token"/>
<add key="ida:Scope" value="openid profile email"/>

For implementing the custom external authentication provider, a custom AuthenticationProvidersInitializer has to be created where the external provider can be configured and then the initializer can be registered in the ObjectFactory.
Once a user logs via SSO with the STS in the relying party instance, in case there is no user previously authenticated with the same email, a new local user account is automatically created. The profile fields of the account are populated with the information provided by the STS in the claims that are returned. Profile fields of the local account (in the relying party instance) are updated only when they are empty and only from the claims received by the STS. Thus, if you edit your first name in the relying party instance, the change is not synced with the first name on the STS. Once the account is created locally, it is bound to the identity authenticated via email by the STS. If the email is modified either in the STS, or in the local profile in the relying party instance, a new account is once again created for the external user when they log in. If this is the case, all local profile information and local application roles are lost.

public class AuthenticationProvidersInitializerExtender : AuthenticationProvidersInitializer
{
    // App config settings
    public static string ClientId = ConfigurationManager.AppSettings["ida:ClientId"];
    public static string AadInstance = ConfigurationManager.AppSettings["ida:AadInstance"];
    public static string Tenant = ConfigurationManager.AppSettings["ida:Tenant"];
    public static string Issuer = ConfigurationManager.AppSettings["ida:Issuer"];
    public static string RedirectUri = ConfigurationManager.AppSettings["ida:RedirectUri"];
    public static string PostLogoutRedirectUri = ConfigurationManager.AppSettings["ida:PostLogoutRedirectUri"];
    public static string ProfileRedirectUri = ConfigurationManager.AppSettings["ida:ProfileRedirectUri"];
    public static string SignUpSignInPolicyId = ConfigurationManager.AppSettings["ida:SignUpSignInPolicyId"];
    public static string ResponseType = ConfigurationManager.AppSettings["ida:ResponseType"];
    public static string Scope = ConfigurationManager.AppSettings["ida:Scope"];

    public override Dictionary<string, Action<IAppBuilder, string, AuthenticationProviderElement>> GetAdditionalIdentityProviders()
    {
        var providers = base.GetAdditionalIdentityProviders();

        string providerName = "AzureB2C";
        providers.Add(providerName, (IAppBuilder app, string signInAsType, AuthenticationProviderElement providerConfig) =>
        {
            var options = new OpenIdConnectAuthenticationOptions()
            {
                ClientId = ClientId,
                Authority = Issuer + "/",
                AuthenticationType = "OpenIDConnect",
                SignInAsAuthenticationType = signInAsType,
                RedirectUri = RedirectUri,
                ResponseType = ResponseType,
                PostLogoutRedirectUri = PostLogoutRedirectUri,
                Scope = Scope,
                MetadataAddress = string.Format(AadInstance, Tenant, SignUpSignInPolicyId),
                Notifications = new OpenIdConnectAuthenticationNotifications()
                {
                    SecurityTokenValidated = n => this.SecurityTokenValidatedInternal(n),
                    AuthenticationFailed = n => this.OnAuthenticationFailed(n),
                }
            };

            app.UseOpenIdConnectAuthentication(options);
        });

        return providers;
    }

    private Task SecurityTokenValidatedInternal(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
    {
        var identity = notification.AuthenticationTicket.Identity;

        var externalUserEmail = identity.FindFirst("emails");
        if (externalUserEmail != null)
            identity.AddClaim(new Claim(SitefinityClaimTypes.ExternalUserEmail, externalUserEmail.Value));

        var externalUserId = identity.FindFirst("sub");
        if (externalUserId != null)
            identity.AddClaim(new Claim(SitefinityClaimTypes.ExternalUserId, externalUserId.Value));

        var externalUserFirstName = identity.FindFirst("given_name") != null ? identity.FindFirst("given_name").Value : string.Empty;
        var externalUserFamilyName = identity.FindFirst("family_name") != null ? identity.FindFirst("family_name").Value : string.Empty;
        var externalUserFullName = externalUserFirstName + " " + externalUserFamilyName;
        identity.AddClaim(new Claim(SitefinityClaimTypes.ExternalUserName, externalUserFullName));

        var externalUserNickName = identity.FindFirst("nickname") != null ? identity.FindFirst("nickname").Value : string.Empty;
        identity.AddClaim(new Claim(SitefinityClaimTypes.ExternalUserNickName, externalUserNickName));

        var externalUserPicture = identity.FindFirst("picture");
        if (externalUserPicture != null)
            identity.AddClaim(new Claim(SitefinityClaimTypes.ExternalUserPictureUrl, externalUserPicture.Value));

        return Task.FromResult(0);
    }

    private Task OnAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
    {
        notification.HandleResponse();

        // Handle the error code that Azure AD B2C throws when trying to reset a password from the login page
        // because password reset is not supported by a "sign-up or sign-in policy"
        if (notification.ProtocolMessage.ErrorDescription != null && notification.ProtocolMessage.ErrorDescription.Contains("AADB2C90118"))
        {
            // If the user clicked the reset password link, redirect to the reset password route
            notification.Response.Redirect("/Account/ResetPassword");
        }
        else if (notification.Exception.Message == "access_denied")
        {
            notification.Response.Redirect("/");
        }
        else
        {
            notification.Response.Redirect("/Home/Error?message=" + notification.Exception.Message);
        }

        return Task.FromResult(0);
    }
}

Initializer has to be registered in Global.asax,

protected void Application_Start(object sender, EventArgs e)
        {
            SystemManager.ApplicationStart += SystemManager_ApplicationStart;
        }

        private void SystemManager_ApplicationStart(object sender, EventArgs e)
        {
            // Register the backend logic for the new external provider
            ObjectFactory.Container.RegisterType<AuthenticationProvidersInitializer, AuthenticationProvidersInitializerExtender>(new ContainerControlledLifetimeManager());
        }

Azure B2C comes with different policies for Sign In, Sign Up, Edit Profile and Reset Password. The policy has to be set appropriately before making request to Azure B2C. Sitefinity uses OWIN for authentication which does not support changing policies before making request. To solve this, another OWIN middleware has to be created which will be used when changing policies (which will not work with Sitefinity’s OWIN middleware). A OWIN Startup class (Startup.cs) has be added under project,

public partial class Startup
{
    // The OWIN middleware will invoke this method when the app starts
    public void Configuration(IAppBuilder app)
    {
        // ConfigureAuth defined in other part of the class
        ConfigureAuth(app);
    }
}

Below Startup.Auth.cs has to be added under App_Start,

[assembly: OwinStartup(typeof(SitefinityWebApp.Startup))]

namespace SitefinityWebApp
{
    public partial class Startup
    {
        // App config settings
        public static string ClientId = ConfigurationManager.AppSettings["ida:ClientId"];
        public static string AadInstance = ConfigurationManager.AppSettings["ida:AadInstance"];
        public static string Tenant = ConfigurationManager.AppSettings["ida:Tenant"];
        public static string PostLoginRedirectUri = ConfigurationManager.AppSettings["ida:PostLoginRedirectUri"];
        public static string ProfileRedirectUri = ConfigurationManager.AppSettings["ida:ProfileRedirectUri"];
        public static string SignUpSignInPolicyId = ConfigurationManager.AppSettings["ida:SignUpSignInPolicyId"];
        public static string ResponseType = ConfigurationManager.AppSettings["ida:ResponseType"];
        public static string Scope = ConfigurationManager.AppSettings["ida:Scope"];

        // Configure the OWIN middleware
        public void ConfigureAuth(IAppBuilder app)
        {
            //Use Sitefinity's OWIN Middleware for all requests including Sign Up & Sign In
            app.MapWhen(context => !IsUpdateProfileRequest(context), appBuilder =>
            {
                appBuilder.UseSitefinityMiddleware();
            });

            //Use Custom OWIN Middleware for Edit Profile & Reset Password Request
            app.MapWhen(context => IsUpdateProfileRequest(context), appBuilder =>
            {
                appBuilder.UseCookieAuthentication(new CookieAuthenticationOptions());
                appBuilder.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    //Generate the metadata address using the tenant and policy information
                    MetadataAddress = String.Format(AadInstance, Tenant, SignUpSignInPolicyId),

                    //These are standard OpenID Connect parameters, with values pulled from web.config
                    ClientId = ClientId,
                    RedirectUri = PostLoginRedirectUri,

                    Notifications = new OpenIdConnectAuthenticationNotifications()
                    {
                        RedirectToIdentityProvider = n => this.OnRedirectToIdentityProvider(n),
                    },

                    //Specify the claims to validate
                    ResponseType = ResponseType,
                    AuthenticationType = "OpenIdConnect",
                    SignInAsAuthenticationType = CookieAuthenticationDefaults.AuthenticationType,

                    //Specify the scope by appending all of the scopes requested into one string (seperated by a blank space)
                    Scope = Scope
                });
            });
        }

        private static bool IsUpdateProfileRequest(IOwinContext context)
        {
            return (context.Request.Path.ToString().Contains("/EditProfile") || context.Request.Path.ToString().Contains("/ResetPassword"));
        }

        private Task OnRedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
        {
            var policy = notification.OwinContext.Get<string>("Policy");

            if (!string.IsNullOrEmpty(policy) && !policy.Equals(SignUpSignInPolicyId))
            {
                notification.ProtocolMessage.Scope = OpenIdConnectScopes.OpenId;
                notification.ProtocolMessage.ResponseType = OpenIdConnectResponseTypes.IdToken;
                notification.ProtocolMessage.IssuerAddress = notification.ProtocolMessage.IssuerAddress.Replace(SignUpSignInPolicyId.ToLower(), policy.ToLower());

                //Redirect to a Controller Action (after Profile Update) to avoid being redirected to Sitefinity Login Page through Sitefinity's OWIN middleware
                notification.ProtocolMessage.RedirectUri = ProfileRedirectUri;
            }

            return Task.FromResult(0);
        }
    }
}

Sitefinity’s OWIN Middleware has to be used for Sign Up and Sign In, for the user to access the content inside Sitefinity.
Since Sitefinity comes with a OWIN Startup, the above Startup can be set as preferred in AppSettings, in order to avoid multiple Startup conflicts

<add key="owin:appStartup" value="SitefinityWebApp.Startup" />

The app is now properly configured to communicate with Azure AD B2C by using the OpenID Connect authentication protocol. OWIN manages the details of crafting authentication messages, validating tokens from Azure AD B2C, and maintaining user session. All that remains is to initiate each user’s flow.

[ControllerToolboxItem(Name = "SignIn", Title = "SignIn", SectionName = "MVC")]
public class AccountController : Controller
{
    // App config settings
    public static string PostLoginRedirectUri = ConfigurationManager.AppSettings["ida:PostLoginRedirectUri"];
    public static string EditProfilePolicyId = ConfigurationManager.AppSettings["ida:EditProfilePolicyId"];
    public static string ResetPasswordPolicyId = ConfigurationManager.AppSettings["ida:ResetPasswordPolicyId"];

    public ActionResult Index()
    {
        return View("SignIn");
    }

    public void SignUpSignIn()
    {
        if (!Request.IsAuthenticated)
        {
            var challengeProperties = ChallengeProperties.ForExternalUser("OpenIDConnect", PostLoginRedirectUri);
            HttpContext.GetOwinContext().Authentication.Challenge(challengeProperties, ClaimsManager.CurrentAuthenticationModule.STSAuthenticationType);
            return;
        }
        Response.Redirect("/");
    }

    public void EditProfile()
    {
        HttpContext.GetOwinContext().Set("Policy", EditProfilePolicyId);
        HttpContext.GetOwinContext().Authentication.Challenge();
    }

    public void ResetPassword()
    {
        HttpContext.GetOwinContext().Set("Policy", ResetPasswordPolicyId);
        HttpContext.GetOwinContext().Authentication.Challenge();
    }

    public void SignOut()
    {
        if (Request.IsAuthenticated)
        {
            IEnumerable<AuthenticationDescription> authTypes = HttpContext.GetOwinContext().Authentication.GetAuthenticationTypes();
            HttpContext.GetOwinContext().Authentication.SignOut(authTypes.Select(t => t.AuthenticationType).ToArray());
        }
    }

    public void EditProfileRedirect()
    {
        Response.Redirect("/");
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *