How to Add Multiple Authentication Providers to an Optimizely CMS 12 Site (Entra ID, Google, Facebook, and Local Identity)

Modern websites often need to let users sign in with their corporate account (Entra ID), their social identity (Google, Facebook), or a simple email/password for internal users.

If you’re building on Optimizely CMS 12, you can support all of them at once — without breaking the built-in CMS login system.

In this post, we’ll walk through how to:

  • Keep Optimizely CMS 12 local Identity (email/password) at /util/login
  • Add Entra ID (Azure AD) at /login
  • Add Google at /login/google
  • Add Facebook at /login/facebook
  • Use one shared cookie for all external providers
  • Automatically synchronize users/roles into Optimizely

NuGet packages to install

dotnet add package EPiServer.CMS.UI.AspNetIdentity
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
dotnet add package Microsoft.IdentityModel.Protocols.OpenIdConnect
dotnet add package Microsoft.AspNetCore.Authentication.Google
dotnet add package Microsoft.AspNetCore.Authentication.Facebook

You already have Optimizely CMS 12 packages in your project; the first package above brings in the Identity helpers used by the CMS UI.

Files to add

// File: Infrastructure/Security/AuthenticationExtensions.cs
using System.Security.Claims;
using System.Text;
using EPiServer.Cms.UI.AspNetIdentity;
using EPiServer.ServiceLocation;
using EPiServer.Shell.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

namespace YourNamespace.Infrastructure.Security
{
    public static class AuthenticationExtensions
    {
        private const string ExternalAppCookie = "azure-cookie"; // shared external cookie

        /// <summary>
        /// Keep Optimizely CMS local Identity (email/password). Exposes /util/login.
        /// </summary>
        public static IServiceCollection UseOptimizelyCmsIdentity<TUser>(
            this IServiceCollection services,
            IConfiguration configuration) where TUser : SiteUser, new()
        {
            services.AddCmsAspNetIdentity<TUser>(o =>
            {
                var conn = configuration.GetConnectionString("EPiServerDB");
                if (!string.IsNullOrWhiteSpace(conn))
                {
                    o.ConnectionStringOptions = new ConnectionStringOptions
                    {
                        Name = "EPiServerDB",
                        ConnectionString = conn
                    };
                }
            });            

            return services;
        }

        /// <summary>
        /// Adds Entra ID (Azure AD) via OpenIdConnect. Uses the shared external cookie.
        /// Required config keys:
        /// Authentication:AzureClientID, Authentication:AzureClientSecret, Authentication:azureAuthority, Authentication:CallbackPath
        /// </summary>
        public static IServiceCollection UseEntraIdForCms(this IServiceCollection services, IConfiguration configuration)
        {
            Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; // helpful during setup

            var clientId       = configuration["Authentication:AzureClientID"];
            var clientSecret   = configuration["Authentication:AzureClientSecret"];
            var azureAuthority = configuration["Authentication:azureAuthority"]; // e.g. https://login.microsoftonline.com/<tenant>/v2.0
            var callbackPath   = configuration["Authentication:CallbackPath"] ?? "/signin-oidc";

            services.AddAuthentication()
                .AddCookie(ExternalAppCookie, options =>
                {
                    // Any external login that signs into this cookie will trigger sync
                    options.Events = new CookieAuthenticationEvents
                    {
                        OnSignedIn = async ctx =>
                        {
                            if (ctx.Principal?.Identity is ClaimsIdentity id)
                            {
                                var sync = ctx.HttpContext.RequestServices.GetRequiredService<ISynchronizingUserService>();
                                await sync.SynchronizeAsync(id);
                            }
                        }
                    };
                })
                .AddOpenIdConnect("azure", options =>
                {
                    options.SignInScheme = ExternalAppCookie;
                    options.ResponseType = OpenIdConnectResponseType.Code;
                    options.UsePkce = true;

                    options.ClientId = clientId;
                    options.ClientSecret = clientSecret;
                    options.Authority = azureAuthority;
                    options.CallbackPath = new PathString(callbackPath);

                    // Ensure standard profile/email claims flow in for synchronization
                    options.Scope.Clear();
                    options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
                    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
                    options.Scope.Add(OpenIdConnectScope.Email);

                    options.MapInboundClaims = false;               // we’ll control mappings
                    options.GetClaimsFromUserInfoEndpoint = true;   // pull extra claims if needed

                    options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
                    options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
                    options.ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");

                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        RoleClaimType = "roles",
                        NameClaimType = "preferred_username",
                        ValidateIssuer = false
                    };

                    options.Events = new OpenIdConnectEvents
                    {
                        OnRedirectToIdentityProvider = ctx =>
                        {
                            if (ctx.Response.StatusCode == StatusCodes.Status401Unauthorized)
                            {
                                ctx.HandleResponse();
                            }
                            return Task.CompletedTask;
                        },
                        OnAuthenticationFailed = context =>
                        {
                            context.HandleResponse();
                            context.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(context.Exception.Message));
                            return Task.CompletedTask;
                        },
                        OnTokenValidated = ctx =>
                        {
                            // Safe redirect normalization (avoid relative-URI exception)
                            var redirect = ctx.Properties?.RedirectUri;
                            if (!string.IsNullOrEmpty(redirect) &&
                                Uri.TryCreate(redirect, UriKind.RelativeOrAbsolute, out var uri) &&
                                uri.IsAbsoluteUri)
                            {
                                ctx.Properties.RedirectUri = uri.PathAndQuery;
                            }

                            // Tag provider
                            if (ctx.Principal?.Identity is ClaimsIdentity id)
                            {
                                id.AddClaim(new Claim("auth_provider", "entra"));
                            }

                            // Fire-and-forget sync (cookie event will also run on next req)
                            ServiceLocator.Current
                                .GetInstance<ISynchronizingUserService>()
                                .SynchronizeAsync(ctx.Principal?.Identity as ClaimsIdentity);

                            return Task.CompletedTask;
                        }
                    };
                });

            return services;
        }

        /// <summary>
        /// Adds Google OAuth. Signs into the shared external cookie.
        /// </summary>
        public static IServiceCollection UseGoogleForCms(this IServiceCollection services, IConfiguration configuration)
        {
            var clientId     = configuration["Authentication:Google:ClientId"];
            var clientSecret = configuration["Authentication:Google:ClientSecret"];

            services.AddAuthentication()
                .AddGoogle("google", options =>
                {
                    options.SignInScheme = ExternalAppCookie;
                    options.ClientId     = clientId;
                    options.ClientSecret = clientSecret;

                    options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
                    options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
                    options.ClaimActions.MapJsonKey(ClaimTypes.Surname, "family_name");

                    options.Events = new OAuth.OAuthEvents
                    {
                        OnCreatingTicket = ctx =>
                        {
                            if (ctx.Principal?.Identity is ClaimsIdentity id)
                                id.AddClaim(new Claim("auth_provider", "google"));
                            return Task.CompletedTask;
                        }
                    };
                });

            return services;
        }

        /// <summary>
        /// Adds Facebook OAuth. Signs into the shared external cookie.
        /// Requires Authentication:Facebook:AppId and :AppSecret.
        /// </summary>
        public static IServiceCollection UseFacebookForCms(this IServiceCollection services, IConfiguration configuration)
        {
            var appId     = configuration["Authentication:Facebook:AppId"];
            var appSecret = configuration["Authentication:Facebook:AppSecret"];

            services.AddAuthentication()
                .AddFacebook("facebook", options =>
                {
                    options.SignInScheme = ExternalAppCookie;

                    options.AppId     = appId;
                    options.AppSecret = appSecret;

                    options.Scope.Clear();
                    options.Scope.Add("email");
                    options.Scope.Add("public_profile");

                    options.Fields.Add("email");
                    options.Fields.Add("first_name");
                    options.Fields.Add("last_name");
                    options.Fields.Add("name");

                    options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
                    options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "first_name");
                    options.ClaimActions.MapJsonKey(ClaimTypes.Surname, "last_name");

                    options.Events = new OAuth.OAuthEvents
                    {
                        OnCreatingTicket = ctx =>
                        {
                            if (ctx.Principal?.Identity is ClaimsIdentity id)
                                id.AddClaim(new Claim("auth_provider", "facebook"));
                            return Task.CompletedTask;
                        }
                    };
                });

            return services;
        }
    }
}

// File: Infrastructure/Security/MultiAuthExtensions.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;

namespace YourNamespace.Infrastructure.Security
{
    public static class MultiAuthExtensions
    {
        public static IServiceCollection UseMultiAuthGateway(this IServiceCollection services)
        {
            const string AppCookie = "azure-cookie";
            const string IdentityCookie = IdentityConstants.ApplicationScheme;

            services.AddAuthentication()
                // Authenticate: choose whichever cookie is present
                .AddPolicyScheme("smart-auth", "Smart Auth", options =>
                {
                    options.ForwardDefaultSelector = ctx =>
                    {
                        var cookies = ctx.Request.Cookies;
                        if (cookies.ContainsKey(".AspNetCore." + AppCookie))    return AppCookie;
                        if (cookies.ContainsKey(".AspNetCore." + IdentityCookie)) return IdentityCookie;
                        return AppCookie; // default
                    };
                })
                // Challenge: route by path
                .AddPolicyScheme("smart-challenge", "Smart Challenge", options =>
                {
                    options.ForwardDefaultSelector = ctx =>
                    {
                        var path = (ctx.Request.Path.Value ?? string.Empty).ToLowerInvariant();

                        if (path.StartsWith("/login/google"))   return "google";
                        if (path.StartsWith("/login/facebook")) return "facebook";
                        if (path.StartsWith("/login"))          return "azure";                 // Entra ID
                        if (path.StartsWith("/util/login"))     return IdentityCookie;          // Optimizely local

                        return "azure"; // default challenge
                    };
                });

            // Make our policy schemes the defaults
            services.PostConfigure<AuthenticationOptions>(o =>
            {
                o.DefaultScheme             = "smart-auth";
                o.DefaultAuthenticateScheme = "smart-auth";
                o.DefaultChallengeScheme    = "smart-challenge";
                o.DefaultSignInScheme       = AppCookie; // shared external cookie
            });

            return services;
        }
    }
}

// File: Controllers/LoginController.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;

namespace YourNamespace.Controllers
{
    [Route("login")]
    public class LoginController : Controller
    {
        [HttpGet("")]
        public IActionResult Microsoft([FromQuery] string returnUrl = "/")
            => Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, "azure");

        [HttpGet("google")]
        public IActionResult Google([FromQuery] string returnUrl = "/")
            => Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, "google");

        [HttpGet("facebook")]
        public IActionResult Facebook([FromQuery] string returnUrl = "/")
            => Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, "facebook");

		public async Task<IActionResult> Logout()
		{
			var authProvider = User.FindFirst("auth-provider")?.Value;

			if (string.IsNullOrEmpty(authProvider))
			{
				await signInManager.SignOutAsync();
			}
			else
			{
				await ControllerContext.HttpContext.SignOutAsync("azure-cookie");
				HttpContext.Response.Cookies.Delete($".AspNetCore.{"azure-cookie"}");
			}

			return Redirect(urlResolver.GetUrl(PageContext.ContentLink, PageContext.LanguageID) ?? "/");
		}
    }
}

In ConfigureServices:

services.UseOptimizelyCmsIdentity<YourNamespace.Infrastructure.Security.SiteUser>(Configuration);
services.UseEntraIdForCms(Configuration);
services.UseGoogleForCms(Configuration);
services.UseFacebookForCms(Configuration);
services.UseMultiAuthGateway();

In appsettings.json:

{
  "ConnectionStrings": {
    "EPiServerDB": "Server=.;Database=YourCmsDb;Trusted_Connection=True;TrustServerCertificate=True"
  },
  "Authentication": {
    "AzureClientID": "YOUR-ENTRA-CLIENT-ID",
    "AzureClientSecret": "YOUR-ENTRA-CLIENT-SECRET",
    "AzureAuthority": "https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0",
    "CallbackPath": "/signin-oidc",
    "Google": {
      "ClientId": "YOUR-GOOGLE-CLIENT-ID",
      "ClientSecret": "YOUR-GOOGLE-CLIENT-SECRET"
    },
    "Facebook": {
      "AppId": "YOUR-FACEBOOK-APP-ID",
      "AppSecret": "YOUR-FACEBOOK-APP-SECRET"
    }
  }
}

Facebook: ensure your “Valid OAuth Redirect URI” in the Facebook app matches your site’s https://your-host/signin-facebook.
Google: allow https://your-host/signin-google.
Entra: set redirect to https://your-host/signin-oidc.

How it works

  • /util/login uses Optimizely local Identity (email/password) — unchanged.
  • /login, /login/google, /login/facebook route to Entra, Google, Facebook respectively.
  • All external providers sign into the same cookie (azure-cookie).
  • On cookie sign-in, we call ISynchronizingUserService.SynchronizeAsync(...) so users/roles appear in the CMS.
  • We add an "auth_provider" claim so you can tell who logged the user in.

Leave a comment