Mixed-mode Okta OWIN authentication + Optimizely regular login for CMS edit/admin section

Vote:
 

Hi folks,

I'm trying to implement a mixed-mode authentication on .net core Optimizely 12 (EPiServer.CMS 12.3.2, EPiServer.CMS.AspNetCore.Mvc 12.4.1) with an Okta Owin authentication (similar to Azure AD Authentication really) for end users on the public site and a regular Optimizely login for the CMS edit/admin section by login on /util/login

Integrating Okta authentication works just fine using their provided services.AddOktaMvc() (which is just Okta custom version of AddOpenIdConnect() )

But as soon as I add the Optimizely login for the CMS section by adding services.AddCmsAspNetIdentity<ApplicationUser>() I'm getting a redirect loop when trying to log in with Okta : on any controller action having [Authorize] attribute :
- I get redirected to Okta sign in widget
- I log in fine, then get redirected to the Okta callback method which validates the successful login
(all good until there, same as without services.AddCmsAspNetIdentity() addition)
- I am redirected to the page I am trying to access which thinks I am not authenticated, so I'm instantly redirected to Okta login page, but as I'm logged in fine on Okta, I'm instantly redirected to the page I am trying to access and so on forever

It looks like HttpContext.User.Identity is not populated based on the returned infos from Okta (whereas it works just fine if I just comment out services.AddCmsAspNetIdentity<ApplicationUser>(); (see code below)

I was thinking it would be some sort of cookie issue between the 2 authentication modes, so I've tried to use SystemWebChunkingCookieManager as CookieManager and various other things but to no avail.

I'm not sure what I am missing and what needs to be done to get this working.
Optimizely has a documentation page for use case for asp.net only : https://world.optimizely.com/documentation/developer-guides/archive/cms/security2/configuring-mixed-mode-owin-authentication-10-11/ but nothing for .net core, same for an example git repo implementing a mixed mode authentication with Optimizely's services.AddCmsAspNetIdentity(), there is one for asp.net, but nothing for .net core

I have been scouring the internet to try to find something, but to no avail and I'm running out of ideas.

Has anyone been able to set up this mixed mode authentication successfully on .net Core and is willing to share its startup.cs file config for this ?

Many thanks for the help, is has been driving me crazy the last days !

Here is what I have in Startup.cs at the moment : 

using System;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using EPiServer.Cms.UI.AspNetIdentity;
using EPiServer.Data;
using EPiServer.Labs.LinkItemProperty;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using EPiServer.Web.Routing;
using Geta.NotFoundHandler.Infrastructure.Initialization;

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Owin.Host.SystemWeb;
using Okta.AspNetCore;

namespace MyProject.Web
{
    public class Startup
    {

        private readonly IWebHostEnvironment _webHostingEnvironment;
        private readonly IConfiguration _configuration;

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

        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<DataAccessOptions>(o =>
            {
                o.CreateDatabaseSchema = true;
            });

            this.ConfigureAuthentication(services);

            services.AddMvc();
            services.AddRazorPages();

            services.AddCms();

            services.AddFind();

            services.AddLinkItemProperty();

            services.AddTransient<IActionContextAccessor, ActionContextAccessor>();
            services.AddHttpContextAccessor();

            services.Configure<MyProjectGlobalAppSettings>(
                _configuration.GetSection("GlobalAppSettings"));

            services.AddOptions();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseNotFoundHandler();
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseHttpsRedirection();
            }

            app.UseStaticFiles();
            app.UseStaticFiles(new StaticFileOptions
            {
                FileProvider = new PhysicalFileProvider(
                    Path.Combine(Directory.GetCurrentDirectory(), "Areas")),
                RequestPath = "/Areas"
            });
            app.UseStaticFiles(new StaticFileOptions
            {
                FileProvider = new PhysicalFileProvider(
                    Path.Combine(Directory.GetCurrentDirectory(), "ClientResources")),
                RequestPath = "/ClientResources"
            });

            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();

                endpoints.MapDefaultControllerRoute();

                endpoints.MapContent();
            });
        }

        /// <summary>
        /// Configures the authentication handlers for MyProject :
        /// (1) ApplicationCookie - for users logging in via /util/login.aspx or any custom login page
        /// (2) B2CAuthentication - for MyProject customers logging in via OKTA login page
        /// </summary>
        private void ConfigureAuthentication(IServiceCollection services)
        {
            //(1) ApplicationCookie - for users logging in via /util/login.aspx or any custom login page

            services.AddCmsAspNetIdentity<ApplicationUser>();

            services.ConfigureApplicationCookie(options =>
            {
                options.CookieManager = new SystemWebChunkingCookieManager() as ICookieManager;

                options.LoginPath = "/Account/SignIn";
                options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
                options.SlidingExpiration = true;
            });

            //(2) B2C Authentication - for MyProject customers logging in via OKTA login page
            var oktaConfig = _configuration.GetSection("Okta").Get<OktaAppSettings>();

            services.AddAuthentication(options =>
            {
                options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; 
            })
            //maybe a simple .AddCookie() is enough ?
            .AddCookie(options =>
            {
                options.CookieManager = new SystemWebChunkingCookieManager() as ICookieManager;
            })
            .AddOktaMvc(new OktaMvcOptions
            {
                OktaDomain = oktaConfig.B2C.OktaDomain,
                AuthorizationServerId = oktaConfig.B2C.AuthorizationServerId,
                ClientId = oktaConfig.B2C.ClientId,
                ClientSecret = oktaConfig.B2C.ClientSecret,
                Scope = oktaConfig.B2C.Scopes.Split(new string[] { " " }, StringSplitOptions.RemoveEmptyEntries).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(),
                //Maybe next line is not needed ?
                GetClaimsFromUserInfoEndpoint = true,
                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;
                        }
                        //Sync user and the roles to EPiServer in the background
                        ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(ctx.Principal.Identity as ClaimsIdentity);
                        return Task.FromResult(0); 
                    },
                    OnRedirectToIdentityProvider = context =>
                    {
                        // To avoid a redirect loop to 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;
                    }
                }
            });

            //AddOktaMvc() does not allow to set the TokenValidationParameters in its parameters, so we need to do it afterwards
            services.PostConfigureAll<OpenIdConnectOptions>(options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    RoleClaimType = ClaimTypes.Role,
                    NameClaimType = ClaimTypes.Name,
                    ValidateIssuer = false,
                };
            });
        }

        #region various private helper methods

        private static bool IsXhrRequest(HttpRequest request)
        {
            const string xRequestedWith = "X-Requested-With";

            var query = request.Query;
            if ((query != null) && (query[xRequestedWith] == "XMLHttpRequest"))
                return true;

            var headers = request.Headers;
            return (headers != null) && (headers[xRequestedWith] == "XMLHttpRequest");
        }

        #endregion
    }
}
#278493
Edited, Apr 13, 2022 23:07
Vote:
 

You should probably not add an additional cookie handler, Okta most likely already adds one. Remove:

//maybe a simple .AddCookie() is enough ?
.AddCookie(options =>
{
   options.CookieManager = new SystemWebChunkingCookieManager() as ICookieManager;
})

Since you have options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme, ASP.NET Identity is the autentication handler that will authenticate by default. You can explicitly configure which scheme that should authenticate with options.DefaultAuthenticateScheme. But I assume you want both ASP.NET Identity and Okta to authenticate on the publib site? Then you probably have to implement your own version of Microsoft.AspNetCore.Authentication.AuthenticationSchemeProvider and return correct  scheme based on your needs. You can probably have a middleware or filter that also chooses which scheme to authenticate.

If you want users and roles from both Okta (synced users) and ASP.NET Identity to be available in CMS so you can assign access rights to them, then you need to implement your own SecurityEntityProvider. When you call AddCmsAspNetIdentity() we will register an implementation for ASP.NET Identity users automatically, you can intercept SecurityEntityProvider and then add users and roles from SynchronizingRolesSecurityEntityProvider (where you now are syncing the Okta users) as well. If you don't need ASP.NET Identity users in CMS, you can just replace the implementation in the container with SynchronizingRolesSecurityEntityProvider instead.

ASP.NET Identity is using a cookie scheme named 'Identity.Application', if Okta is using the same, then that might be an issue as well. In your PostConfigureAll, where you have access to OpenIdConnectOptions, you can confiure which schemes Okta should use. Make sure these are unique so not the same are used by ASP.NET Identity. It's probably best to be very explicit. If you need to configure to use another cookie, then you need to add that cookie too:

.AddCookie("okta", options =>
{
    options.Cookie.Name = "okta-login";
    options.Events.OnSignedIn = async ctx =>
    {
        // Sync the user to database so they're searchable
        // in the UI when managing access rights.
        if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
        {
            var synchronizingUserService = ctx
                .HttpContext
                .RequestServices
                .GetRequiredService<ISynchronizingUserService>();

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

I would recommend syncing the user here as well. And then use the cookie scheme here:

services.PostConfigureAll<OpenIdConnectOptions>(options =>
{
    options.SignInScheme = "okta";
    options.SignOutScheme = "okta";

    options.TokenValidationParameters = new TokenValidationParameters
    {
        RoleClaimType = ClaimTypes.Role,
        NameClaimType = ClaimTypes.Name,
        ValidateIssuer = false,
    };
});

But hopefully Okta is already using it's own unique cookie scheme.

#278497
Edited, Apr 14, 2022 7:16
Vote:
 

Hi Johan,

Thank you very much for your detailed reply, much appreciated !

This clarified things, but didn't let me solve the issue I am facing :

As per your suggestions I have :
 - removed the SystemWebChunkingCookieManager CookieManager
 - added an custom AuthenticationScheme to the Okta cookie ("okta" in your code example)
 - added a custom cookie name ("okta-login" in your code example)
 
I can confirm that the cookie name is respected and the AuthenticationScheme is also used for the SignInScheme and SignOutScheme.

However my initial issue remains : 

If I comment out  okta login works perfectly fine : the cookie set after loging in on okta is read, HttpContext.User.Claims contains the retrieved claims from the cookie and HttpContext.User.Identity is populated with HttpContext.User.Identity.IsAuthenticated = true and this is persisted accross requests.

However as soon as I add "services.AddCmsAspNetIdentity<ApplicationUser>()" to turn on the Optimizely Identity login (to access the CMS section by login on /util/login with a user previously set in the CMS admin section), HttpContext.User.Claims still contains the retrieved claims from the cookie, however HttpContext.User.Identity is not populated anymore and we have HttpContext.User.Identity.IsAuthenticated = false

I have 2 questions :

1. Would you know what AddCmsAspNetIdentity() is overriding exactly and what code change we should do to still have HttpContext.User.Identity populated when logging with okta while also having AddCmsAspNetIdentity() ?

(we would really need an equivalent documentation to https://world.optimizely.com/documentation/developer-guides/archive/cms/security2/configuring-mixed-mode-owin-authentication-10-11/ for Optimizely 12 on .net core !)

2. Additionally, I haven't really understood the following part of previous reply : "you can intercept SecurityEntityProvider and then add users and roles from SynchronizingRolesSecurityEntityProvider (where you now are syncing the Okta users) as well. If you don't need ASP.NET Identity users in CMS, you can just replace the implementation in the container with SynchronizingRolesSecurityEntityProvider instead."

Would you have an example of code you could share doing what you describe ?


Many thanks !

#278701
Edited, Apr 19, 2022 2:25
Vote:
 

Most importantly the method registeres following services:

services.TryAddTransient<UISignInManager, ApplicationUISignInManager<TUser>>();

services
    .AddTransient<AspNetIdentitySecurityEntityProvider<TUser>>()
    .Forward<AspNetIdentitySecurityEntityProvider<TUser>, SecurityEntityProvider>()
    .Forward<AspNetIdentitySecurityEntityProvider<TUser>, IQueryableNotificationUsers>();

So if you still want the CMS UI to return users and roles from Okta (synced via ISynchronizingUserService), you need to either change the container to use SynchronizingRolesSecurityEntityProvider instead of AspNetIdentitySecurityEntityProvider<TUser>. Or you can intercept SecurityEntityProvider and let it return users and roles from both services. Same thing for IQueryableNotificationUsers, this service is used when setting up workflows in edit mode.

As I previously mentioned, if you have multiple authentication schemes, only the default one will be authenticated. You can also specify which one should be used, on a controller and action level, by specifying the scheme in the authorize attribute [Authorize(AuthenticationSchemes = "okta")]. You need to explicitly specify which one should be authenticated based on your requirements. You can do this by e.g.  implementing your own AuthenticationSchemeProvider.

#278787
Edited, Apr 20, 2022 8:14
Vote:
 

You can also use AuthenticationSchemeOptions.ForwardDefaultSelector to select which sheme should be used for the specific request https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.authenticationschemeoptions.forwarddefaultselector 

services
    .AddAuthentication(options =>
    {
        // Leave empty if you want ASP.NET Identity to be default.
        options.DefaultScheme = "okta";
    })
    // AddCmsAspNetIdentity() already adds this cookie, so don't add this if it's already added,
    // but I included this here to illustrate how everything fits together. 
    // 'Identity.Application' is the default name for the scheme ASP.NET Identity uses.
    .AddCookie("Identity.Application")
    .AddCookie("okta")
    .AddPolicyScheme("policy-scheme", null, options =>
    {
        options.ForwardDefaultSelector = ctx =>
        {
            // Check available cookies or other things available
            // in the context.
            if (ctx.Request.Path.StartsWithSegments("episerver", StringComparison.OrdinalIgnoreCase))
            {
                // All requests starting with 'episerver' will now use ASP.NET Identity.
                return "Identity.Application";
            }

            // All other requests will use Okta.
            return "okta";
        };
    });

Also see https://docs.microsoft.com/en-us/aspnet/core/security/authorization/limitingidentitybyscheme?view=aspnetcore-6.0 

#278799
Edited, Apr 20, 2022 14:13
Vote:
 
#278884
Apr 21, 2022 13:50
* You are NOT allowed to include any hyperlinks in the post because your account hasn't associated to your company. User profile should be updated.