How To Enable Multiplex Login In Your Optimizely CMS 12 Website Using Sustainsys SAML2 idP

If you find this post, then you're probably facing a problem that does not happen everyday.

In the world of Optimizely, everything is possible.
I would say that it's a question of complexity and if something is worth doing or not.

The reason you might want to do this is to have a temporary solution for a smooth transition from AspNetIdentity to using idP for authentication.

So, how does this work and how can we configure this to work with Optimizely CMS 12?

There are some tutorials out there, and with help of other experts in specific areas you will be able to solve this too, but let me save you the time and just spill out everything I have learned. Enjoy!

First we need to set up the configuration in startup, we need to go to startup.cs and find the ConfigureService method. Look for the ConfigureServices(IServiceCollection Service) method.


// some code here
private readonly IWebHostEnvironment _webHostingEnvironment;
private readonly IConfiguration _configuration;

public Startup(IWebHostEnvironment webHostingEnvironment, IConfiguration configuration)
{
   _webHostingEnvironment = webHostingEnvironment;
   this._configuration = configuration;
}

public void ConfigureServices(IServiceCollection services)
{
// some code here

//This should already exist
services.AddCmsAspNetIdentity<ApplicationUser>();

//some code here

//Add this login path for AspNetIdentity
services.ConfigureApplicationCookie(options => 
{
 options.LoginPath = "/util/login";
});

//Add this line to configure idP
services.AddIdp(_configuration);

//some code here
}

You probably already noticed that the services.AddCmsAspNetIdentity<ApplicationUser>(); is the first service that runs in terms of the authentication setup. This is important as I struggled with the multiplex when AddCmsAspNetIdentiy was configured after idP. You should also add the LoginPath for ApplicationUser before idP. When we are finished, there will be 3 endpoints that allows users to authenticate.

  • /util/login will be used for CmsAspNetIdentity
  • /account/login will be used for CMS admins through idP (You can use AD)
  • /external/login will be used for external users through idP (You can use BankID)

We will need to prepare a couple of things, for starters let's create the AddIdp service.
To keep things simple, we will create an configuration for idP that will hold all the logic connected to SAML2. Create a file in your solution, maybe in a configuration folder called ConfigureIDP.cs with the following code:


// using stuff

namespace SampleProject.Web.Configuration
{
  public static IServiceCollection AddIdp(this IServiceCollection services, IConfiguration configuration, bool isDevelopment = false)
    {
       // We will later write some code here
    }
}

Since we are using Optimizely CMS 12, we will need to create two controllers. There will be one for each of the service provider we want to setup. If you only need one Service Provider, then you can skip the other one, however I will continue with two to show how it's done.

Let's create two controllers, AccountController and ExternalAccountController. Note that these controllers will only be used for routing, however you can create login landing pages if you want to. We can keep things simple by creating a page called LoginPage and extend it with BasePage, give it a beautiful GUID and leave the page class empty.

You can use this Guid Generator to generate a GUID.

[ContentType(GUID = "4-B34T1FU1-GU1D-H3R3")]
public class LoginPage : BasePage
{
 //This will be empty, nothing to see here :/
}

Please note: The naming of the controllers, service providers and variables are only for the purpose if this post, as an example.

Let's start with the AccountController, shall we?

[Route("account")]
public class AccountController : PageController<LoginPage>
{
  // This depends on how your ViewModelFactory is setup
  private readonly IGenericViewModelFactory<LoginPage, LoginPageViewModel> LoginPageViewModelFactory;

  public AccountController(IGenericViewModelFactory<LoginPage, LoginPageViewModel> LoginPageViewModelFactory)
   {
     this.LoginPageViewModelFactory = LoginPageViewModelFactory;
   }
}

Let's do the same with ExternalAccountController.

[Route("external")]
public class ExternalAccountController : PageController<LoginPage>
{
  // This depends on how your ViewModelFactory is setup
  private readonly IGenericViewModelFactory<LoginPage, LoginPageViewModel> LoginPageViewModelFactory;

  public ExternalAccountController(IGenericViewModelFactory<LoginPage, LoginPageViewModel> LoginPageViewModelFactory)
   {
     this.LoginPageViewModelFactory = LoginPageViewModelFactory;
   }
}

If you have any questions, feel free to reach out to me on LinkedIn.

If you haven't already, create the stub idp environments you need for this to work by going to Sustainsys.

I prefer to use appsettings.json to store all the schemas and settings that is going be used to set this up properly. I recommend you to do the same. If you are using DevOps, variables should later be moved there to set things up correctly for deployment.

Copy the settings below if you want to save time.

"ApplicationSettings": {
    "IdpSettingsAccount": {
      "PublicOriginURL": "https://domain.local/",
      "ReturnUrl": "https://domain.local/account/login-callback",
      "EntityId": "https://domain.local/Saml2",
      "IdentityProvidersEntityId": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Metadata",
      "SingleSignOnServiceUrl": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
      "SingleLogoutServiceResponseUrl": "https://domain.local/",
      "MetadataLocation": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Metadata",
      "CertificateName": "Sustainsys.Saml2.Tests"
    },
    "IdpSettingsExternal": {
      "PublicOriginURL": "https://domain.local/",
      "ReturnUrl": "https://domain.local/external/login-callback",
      "EntityId": "https://domain.local/external/Saml2",
      "IdentityProvidersEntityId": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Metadata",
      "SingleSignOnServiceUrl": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
      "SingleLogoutServiceResponseUrl": "https://domain.local/",
      "MetadataLocation": "https://stubidp.sustainsys.com/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Metadata"
    }

Now, we are going to configure our ConfigureIDP.cs, which will do the heavy lifting.

If you haven't already, install the Sustainsys SAML2 nuget package.

public static IServiceCollection AddIdp(this IServiceCollection services, IConfiguration configuration, bool isDevelopment = false)
{

   var appSettings = configuration.GetSection(nameof(ApplicationSettings)).Get<ApplicationSettings>();

}

Allright, first we start by loading the appsettings values into the appSettings variable. This will allow us to smoothly insert values into the configurations which later can be changed on deployment.

Then we add the methods shown below to your AddIdp method, under the appSettings variable.

// ...using
// make sure you have Sustainsys.Saml2 nuget in your projekt
using Sustainsys.Saml2;
using Sustainsys.Saml2.Metadata;

public static IServiceCollection AddIdp(this IServiceCollection services, IConfiguration configuration, bool isDevelopment = false)
{
   var appSettings = configuration.GetSection(nameof(ApplicationSettings)).Get<ApplicationSettings>();

services.AddAuthentication(options =>
{
// default scheme that maintains session is cookies.
options.DefaultScheme = "Application";
options.DefaultSignInScheme = "External";
// if there's a challenge to sign in, use the Saml2 scheme.
options.DefaultChallengeScheme = "Application";
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie("Application", options =>
{
   options.Events.OnRedirectToAccessDenied = (context) =>
   {
   };
   options.Events.OnRedirectToLogin = (context) =>
   {
   };
})
.AddCookie("External", options =>
{
  options.Events.OnSignedIn = async ctx =>
   {
   })
}
.AddSaml2("ExternalUser", options =>
{
})
.AddSaml2("CMSAdmin", options =>
{
})
}

In simple terms, we are initializing a new authentication method, and adding SAML2 to it. We are also going to handle events like AccessDenied, RedirectToLogin and OnSignedIn.

You might not need all of this, but if the website your are implementing SAML2 on is running on a on-prem server, this might be an important part.

If the server is set up to only communicate through http internally, but is communicating through https externally, then this is for you.

We are going to call this method to make user EntityId stays correct all the way. If EntityId is changed from https to http during the request, the authentication will fail.

Let's name it HandleInsecureRequest.

.AddCookie("Application", options =>
{
    options.Events.OnRedirectToAccessDenied = (context) =>
    {
// It goes here HandleInsecureRequest(context); return Task.CompletedTask; }; options.Events.OnRedirectToLogin = (context) => { HandleInsecureRequest(context); return Task.CompletedTask; }; })

We are going to create this method outside of the AddIdp method. This one will be pretty simple and straight forward. We are simply replacing the http with a https if needed.

public static IServiceCollection AddIdp(this IServiceCollection services, IConfiguration configuration, bool isDevelopment = false)
{
// lots of code here
}

// Here comes the method, outside of AddIdp private static void HandleInsecureRequest(RedirectContext<CookieAuthenticationOptions> context) { string loginUrl; if (context.Request.Scheme == "http") { loginUrl = context.RedirectUri.Replace("http", "https"); } else { loginUrl = context.RedirectUri; } context.HttpContext.Response.Redirect(loginUrl); }

We also need to configure the certificate that will be used, it's available in the SAML2 repository.

This setup is one way to do it, it all depends on how your servers are configured and what you have to work with.

In this GetCertificate method we should be doing two things. Look for a certificate on the server, if nothing is found, we get the certificate from the SAML2 repository as an fallback.

However, I will keep it simple and just look for my local certificate.

private static void HandleInsecureRequest(RedirectContext<CookieAuthenticationOptions> context)
{
 // code here
}

private static X509Certificate2 GetCertificate()
{
    return new X509Certificate2("C:\\PathToProject\\SampleProject\\Sustainsys.Saml2.Tests.pfx", "", X509KeyStorageFlags.MachineKeySet);
}

Now that this is out of the way, let's get back to the authentication.

Remember AddCookie("External") we created?

It's time to create some magic.
This is one of the best part about this implementation, Optimizely CMS has an beautiful way of taking the information of the user who is trying authenticate and inserting the values into the database.

Just amazing.

.AddCookie("External", options =>
{
    options.Events.OnSignedIn = async ctx =>
    {
        if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
        {
            if (claimsIdentity.Name != null)
            {
                // Syncs user and roles so they are available in the CMS
                var synchronizingUserService = ctx
                .HttpContext
                .RequestServices
                .GetRequiredService<ISynchronizingUserService>();

                await synchronizingUserService.SynchronizeAsync(claimsIdentity);
            }
        }
    };
})

Let's continue, we don't want to be stuck drooling over this.

Now we will be creating both of the Saml2 schemas that will be used for each authentication method.
We will start with the users that will use idP to sign in with BankId.

.AddSaml2("ExternalUser", options => 
{
// configure ServiceProvider with values from appsettings
options.SPOptions.EntityId = new EntityId(appSettings.IdpSettingsExternal.EntityId);
options.SPOptions.ReturnUrl = new Uri(appSettings.IdpSettingsExternal.ReturnUrl);

// this one is important, since we want to use two ServiceProviders 
options.SPOptions.ModulePath = "/external/Saml2";
options.SPOptions.ServiceCertificates.Add(
   new ServiceCertificate
       {
          Certificate = GetCertificate()
       }
   );
}

ModulePath will help you separate the paths for the MetaData file, standard value is "/Saml2/" which will be used for CMS admins and "/External/Saml2/" will be used for external users.

Create a new options under the ServiceCertificate called options.Notifications.AcsCommandResultCreated

options.Notifications.AcsCommandResultCreated = (c, r) =>
{
    if (c.Principal.Identity is ClaimsIdentity claimsIdentity)
    {
//Here you handle claims for external users, create accounts or verify user data
}
}

If you are using AuthenticationProperties in the controller, which we will talking about later, you can fetch that information in this section by listening to RelayData.

options.Notifications.AcsCommandResultCreated = (c, r) =>
{
    if (c.Principal.Identity is ClaimsIdentity claimsIdentity)
    {
       if (c.RelayData.Any(x => x.Key.Equals("someKey")))
{
    var value = c.RelayData["someKey"];
}
}
}

The last thing we need to do for external users in this configuration is adding the IdentityProvider.
You probably won't need all these settings.


var dontJustCopyAndPaste = new IdentityProvider(new EntityId(appSettings.IdpSettingsExternal.IdentityProvidersEntityId), options.SPOptions)
{
    MetadataLocation = appSettings.IdpSettingsExternal.MetadataLocation,
    LoadMetadata = true,
    AllowUnsolicitedAuthnResponse = true,
    Binding = Sustainsys.Saml2.WebSso.Saml2BindingType.HttpRedirect,
    SingleSignOnServiceUrl = new Uri(appSettings.IdpSettingsExternal.SingleSignOnServiceUrl),
};

options.IdentityProviders.Add(dontJustCopyAndPaste);

This is how the final version should look like


.AddSaml2("ExternalUser", options => 
{
// configure ServiceProvider with values from appsettings
options.SPOptions.EntityId = new EntityId(appSettings.IdpSettingsExternal.EntityId);
options.SPOptions.ReturnUrl = new Uri(appSettings.IdpSettingsExternal.ReturnUrl);

// this one is important, since we want to use two ServiceProviders 
options.SPOptions.ModulePath = "/external/Saml2";
options.SPOptions.ServiceCertificates.Add(
   new ServiceCertificate
       {
          Certificate = GetCertificate()
       }
   );
}    

options.Notifications.AcsCommandResultCreated = (c, r) =>
{
  if (c.Principal.Identity is ClaimsIdentity claimsIdentity)
  {
     //Here you handle claims for external users, create accounts or verify user data
     //Handle RelayData
     if (c.RelayData.Any(x => x.Key.Equals("someKey")))
     {
        var value = c.RelayData["someKey"];
     }
  }
}
var dontJustCopyAndPaste = new IdentityProvider(new EntityId(appSettings.IdpSettingsExternal.IdentityProvidersEntityId), options.SPOptions)
{
    MetadataLocation = appSettings.IdpSettingsExternal.MetadataLocation,
    LoadMetadata = true,
    AllowUnsolicitedAuthnResponse = true,
    Binding = Sustainsys.Saml2.WebSso.Saml2BindingType.HttpRedirect,
    SingleSignOnServiceUrl = new Uri(appSettings.IdpSettingsExternal.SingleSignOnServiceUrl),
};

options.IdentityProviders.Add(dontJustCopyAndPaste);
})
    

Congrats, you have now successfully configured one ServiceProvider.
Let's set up the same for CMS admins by doing the same thing in .AddSaml2("CMSAdmin").


.AddSaml2("CMSAdmin", options => 
{
// configure ServiceProvider with values from appsettings
options.SPOptions.EntityId = new EntityId(appSettings.IdpSettingsAccount.EntityId);
options.SPOptions.ReturnUrl = new Uri(appSettings.IdpSettingsAccount.ReturnUrl);

// No need to configure ModulePath, default is "/Saml2" 
options.SPOptions.ServiceCertificates.Add(
   new ServiceCertificate
       {
          Certificate = GetCertificate()
       }
   );
}    

options.Notifications.AcsCommandResultCreated = (c, r) =>
{
  if (c.Principal.Identity is ClaimsIdentity claimsIdentity)
  {
     //Here you handle claims or verify user data
     //if the user for some reason isn't allowed, you can handle it here.
     //you can call another service, method or just look into the claims
     var userIsAuthorized = callAMethod.WithSomeLogic(claimsIdentity.HasClaim(c => c.Type == ClaimTypes.Role && c.Value.Contains("RoleBasedAuthorizaionOrSomething")))
     
     if (!userIsAuthorized)
     {
        c.HttpStatusCode = System.Net.HttpStatusCode.Redirect;
        c.Location = new Uri($"{appSettings.IdpSettingsAccount.PublicOriginURL}", UriKind.Absolute);
        return;
     }
  }
}
var dontJustCopyAndPaste = new IdentityProvider(new EntityId(appSettings.IdpSettingsExternal.IdentityProvidersEntityId), options.SPOptions)
{
    MetadataLocation = appSettings.IdpSettingsExternal.MetadataLocation,
    LoadMetadata = true,
    AllowUnsolicitedAuthnResponse = true,
    Binding = Sustainsys.Saml2.WebSso.Saml2BindingType.HttpRedirect,
    SingleSignOnServiceUrl = new Uri(appSettings.IdpSettingsExternal.SingleSignOnServiceUrl),
};

options.IdentityProviders.Add(dontJustCopyAndPaste);
})
    

That should do it.

For all this to trigger properly we need to go back to the controllers.

Go to the AccountController.

[Route("account")]
public class AccountController : PageController<LoginPage>
{
  // some code here
  [HttpGet("Login")]
public IActionResult Login(string returnUrl)
{
	string returnUrlParam = string.IsNullOrEmpty(returnUrl) ? string.Empty : $"?returnUrl={returnUrl}";

	var authenticationProperties = new AuthenticationProperties
    {
        RedirectUri = "/Account/LoginCallback" + returnUrlParam,
    };

    var challengeResult = new ChallengeResult("CMSAdmin", authenticationProperties);

    return challengeResult;
}

[HttpGet("LoginCallback")]
public async Task LoginCallback(string returnUrl)
{
    var authenticationResult = await HttpContext.AuthenticateAsync("External");
    if (!authenticationResult.Succeeded)
    {
        return Unauthorized();
    }

    var principal = new ClaimsPrincipal(authenticationResult.Principal.Identity);
    
    await AuthenticationHttpContextExtensions.SignInAsync(HttpContext, CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent = false });
    if (string.IsNullOrEmpty(returnUrl))
    {
        returnUrl = "/";
    }

    HttpContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(
            new RequestCulture("en-US")));
    return Redirect(returnUrl);
}
}

If everything is wired up correctly, you should be redirected to your IDP by going to /account/login.

Do the same with ExternalAccountController.

You can also add an SignOut and a method to send data through RelayData.

[Route("external")]
public class ExternalAccountController : PageController<LoginPage>
{
  // All the code above, but for this controller.
  
  [HttpGet("Verify")]
public IActionResult Verify(string someValue)
{
    //If you need to verify something here before creating an account
var statusPageUrl = "https://domain.local/status"; var authenticationProperties = new AuthenticationProperties { RedirectUri = statuspageUrl }; authenticationProperties.Items.Add("someKey", someValue); var challengeResult = new ChallengeResult("ExternalUser", authenticationProperties); return challengeResult; } [HttpGet("SignOut")] public IActionResult SignOut() { var config = ServiceLocator.Current.GetInstance(); string logoutUrl = config.GetValue("ApplicationSettings:IdpSettingsExternal:SingleLogoutServiceResponseUrl"); var authenticationProperties = new AuthenticationProperties { RedirectUri = logoutUrl }; return SignOut(authenticationProperties, CookieAuthenticationDefaults.AuthenticationScheme, "ExternalUser"); } }

If you want to verify that your SignOut works correctly, sign in as an external user, then go to /external/signout.

Congrats, you have now implemented SAML2 authentication in Optimizely CMS 12!

Optimizely CMS SAML2 IDP Authentication