Back-End Development

Update ClaimsIdentity from Okta to Optimizely

Username Password Shutterstock Wordpress

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.

https://world.optimizely.com/forum/developer-forum/CMS/Thread-Container/2022/4/mixed-mode-okta-owin-authentication–optimizely-regular-login-for-cms-editadmin-section/

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.

Claimsidentity Values from Chrome

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.

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Joe Mayberry

Joe Mayberry is a Senior Technical Consultant specializing in the Optimizely CMS platform, with over 15 years of experience working with content management systems.

More from this Author

Follow Us
TwitterLinkedinFacebookYoutubeInstagram