We had a project come in for a new Optimizely CMS 12 site, which required SSO integration with Okta. I didn’t have experience setting up SSO with CMS 12, so I started reading documentation, blogs, and forum posts. Most of what I found was for earlier versions of the CMS and not directly applicable anymore. I could not find much online about using Okta for authentication on CMS 12.
I finally found a forum post that had something that helped me over the initial roadblock of getting the authentication to work. (Thank you to Ludovic Royer for asking the question and providing such a useful code example.
I am not sure if all Okta authentication responses are set up the same or if they can be configured (I suspected the latter). Still, for my implementation, Okta was returning the common name (First and Last name) as the “Name” value in the ClaimsIdentity object.
This is not usually a problem. However, when using the ISynchonizingUserService to synchronize the ClaimsIdentity, the CMS used the “Name” value as the username in Optimizely. This was a big problem. With a large organization, there is likely more than one user with the same first and last name.
All the documentation I found said I could override this by setting the “preferred_username” property in the ClaimsIdentity object. But, for the life of me, I could not figure out how to set that property into my ClaimsIdentity object. The property was read-only and not something that I could update.
The Solution
Like most good solutions, though, this one turned out to be rather simple. I came up with this solution to create a new ClaimsIdentity object, set the needed properties, and pass that new object to the SynchronizeAsync function. Those new values then went into the tblSynchedUsers tables in the CMS.
// Set name values into Identity claims for sync into Optimizely private static ClaimsIdentity AddUserInfoClaims(ClaimsPrincipal claimsPrincipal) { var authClaimsIdentity = new ClaimsIdentity(claimsPrincipal.Identity.AuthenticationType, CustomClaimNames.EpiUsername, ClaimTypes.Role); var name = claimsPrincipal.Claims.FirstOrDefault(x => x.Type == "name").Value; var email = claimsPrincipal.Claims.FirstOrDefault(x => x.Type == "email").Value; var preferredUsername = claimsPrincipal.Claims.FirstOrDefault(x => x.Type == "preferred_username").Value ?? email; var nameAry = name.Split(" ", StringSplitOptions.RemoveEmptyEntries); var givenName = nameAry[0]; var surName = string.Empty; if (nameAry.Length == 2) { surName = nameAry[1]; } else { surName = nameAry[2]; } authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Name, preferredUsername)); authClaimsIdentity.AddClaim(new Claim(CustomClaimNames.EpiUsername, preferredUsername)); authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Email, email)); authClaimsIdentity.AddClaim(new Claim(ClaimTypes.GivenName, givenName)); authClaimsIdentity.AddClaim(new Claim(ClaimTypes.Surname, surName)); return authClaimsIdentity; }
This next section of the code is the workhorse of the authentication, where I struggled until finding Ludovic Royer’s forum post. I am very grateful to have others in the community share their hard work.
public static IServiceCollection AddOktaAuthentication(this IServiceCollection services, IConfiguration configuration) { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = configuration.GetValue<bool>("Okta:ShowPII"); services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }) .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOktaMvc(new OktaMvcOptions() { OktaDomain = configuration.GetValue<string>("Okta:OktaDomain"), AuthorizationServerId = configuration.GetValue<string>("Okta:AuthorizationServerId"), ClientId = configuration.GetValue<string>("Okta:ClientId"), ClientSecret = configuration.GetValue<string>("Okta:ClientSecret"), PostLogoutRedirectUri = configuration.GetValue<string>("Okta:PostLogoutRedirectUrl"), GetClaimsFromUserInfoEndpoint = true, Scope = configuration.GetValue<string>("Okta:Scope").Split(new string[] { " " }, StringSplitOptions.RemoveEmptyEntries).ToList(), OpenIdConnectEvents = new OpenIdConnectEvents { OnAuthenticationFailed = context => { context.HandleResponse(); context.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(context.Exception.Message)); return Task.FromResult(0); }, OnTokenValidated = (ctx) => { var redirectUri = new Uri(ctx.Properties.RedirectUri, UriKind.RelativeOrAbsolute); if (redirectUri.IsAbsoluteUri) { ctx.Properties.RedirectUri = redirectUri.PathAndQuery; } // Update user Identity with minimal claim properties and roles var authClaimsIdentity = AddUserInfoClaims(ctx.Principal); authClaimsIdentity = AddRoleClaims(authClaimsIdentity, ctx.Principal); // Sync user and the roles to Optimizely CMS in the background ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(authClaimsIdentity); // replace the current Principal object with our new identity ctx.Principal = new ClaimsPrincipal(authClaimsIdentity); return Task.FromResult(0); }, OnRedirectToIdentityProvider = context => { // To avoid a redirect loop in the federation server, send 403 // when user is authenticated but does not have access if (context.Response.StatusCode == 401 && context.HttpContext.User.Identity.IsAuthenticated) { context.Response.StatusCode = 403; context.HandleResponse(); } // XHR requests cannot handle redirects to a login screen, return 401 if (context.Response.StatusCode == 401 && IsXhrRequest(context.Request)) { context.HandleResponse(); } return Task.CompletedTask; }, } }); }
Is this the most elegant or best solution? Probably not, and if there is a better way to do this, please share it with me. However, this solution works quickly, efficiently, without any issues, and the client is happy. That was the primary goal.