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.