openid-connectowinepiserver.net-4.6.2

EPiServer OWIN - debugging 401 problems (role not recognized?)


Having some trouble identifying why I'm getting 401 errors despite having authenticated and having the valid roles. For context, I'm running:

My site is running two separate UseOpenIdConnectAuthentication instances - one to handle front-end authentication using Azure AD B2C and one for back-end using Azure AD. The front-end one works properly (possibly because we aren't using role-based authentication). The latter doesn't seem to be working.

The authentication part works properly - sort of. Navigating directly to /episerver throws the error Error message 401.2.: Unauthorized: Logon failed due to server configuration, and does not get caught by the RedirectToIdentityProvider block. Navigating to the path configured in ConfigurationManager.AppSettings["AAD.LoginPath"] properly gets caught by the app.Use block, and then directs me to the Azure AD page for authenticating, and properly redirects me to /episerver thereafter. However, this time rather than giving the aforementioned 401.2, it hits the RedirectToIdentityProvider block, because it is returning a 401. Uncommenting the app.Use block which tried to catch /episerver does not change anything in the first scenario, and though it does catch in the second scenario, it doesn't solve anything.

The root of the problem seems to me that it isn't recognizing the role, despite the authenticated user having valid claims for the role - namely, handled by this part: <add name="Administrators" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="Administrators" mode="Any" />

Please see below for the relevant parts of my web.config and Startup.cs files. If there's anything else I can provide that would help identify the problem, please let me know. Thanks!

  <system.web>
    <authentication mode="None" />
    <membership>
      <providers>
        <clear />
      </providers>
    </membership>
    <roleManager enabled="false">
      <providers>
        <clear />
      </providers>
    </roleManager>
    <anonymousIdentification enabled="true" />
  </system.web>
  <episerver.framework createDatabaseSchema="true" updateDatabaseSchema="true">
    <appData basePath="App_Data" />
    <scanAssembly forceBinFolderScan="true" />
    <securityEntity>
        <providers>
            <add name="SynchronizingProvider" type="EPiServer.Security.SynchronizingRolesSecurityEntityProvider, EPiServer" />
        </providers>
    </securityEntity>
    <virtualRoles addClaims="true">
      <providers>
        <add name="Administrators" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="Administrators" mode="Any" />
        ...
      </providers>
    </virtualRoles>
    <virtualPathProviders>
      <clear />
      <add name="ProtectedModules" virtualPath="~/EPiServer/" physicalPath="Modules\_Protected" type="EPiServer.Web.Hosting.VirtualPathNonUnifiedProvider, EPiServer.Framework.AspNet" />
    </virtualPathProviders>
  </episerver.framework>
  <location path="Modules/_Protected">
    <system.webServer>
      <validation validateIntegratedModeConfiguration="false" />
      <handlers>
        <clear />
        <add name="BlockDirectAccessToProtectedModules" path="*" verb="*" preCondition="integratedMode" type="System.Web.HttpNotFoundHandler" />
      </handlers>
    </system.webServer>
  </location>

  <location path="episerver">
    <system.web>
      <httpRuntime maxRequestLength="1000000" requestValidationMode="2.0" />
      <pages enableEventValidation="true" enableViewState="true" enableSessionState="true" enableViewStateMac="true">
        <controls>
          <add tagPrefix="EPiServerUI" namespace="EPiServer.UI.WebControls" assembly="EPiServer.UI" />
          <add tagPrefix="EPiServerScript" namespace="EPiServer.ClientScript.WebControls" assembly="EPiServer.Cms.AspNet" />
          <add tagPrefix="EPiServerScript" namespace="EPiServer.UI.ClientScript.WebControls" assembly="EPiServer.UI" />
        </controls>
      </pages>
      <globalization requestEncoding="utf-8" responseEncoding="utf-8" />
      <authorization>
        <allow roles="WebEditors, WebAdmins, Administrators, UHCSiteEditors" />
        <deny users="*" />
      </authorization>
    </system.web>
    <system.webServer>
      <handlers>
        <clear />
        <add name="AssemblyResourceLoader-Integrated-4.0" path="WebResource.axd" verb="GET,DEBUG" type="System.Web.Handlers.AssemblyResourceLoader" preCondition="integratedMode,runtimeVersionv4.0" />
        <add name="PageHandlerFactory-Integrated-4.0" path="*.aspx" verb="GET,HEAD,POST,DEBUG" type="System.Web.UI.PageHandlerFactory" preCondition="integratedMode,runtimeVersionv4.0" />
        <add name="SimpleHandlerFactory-Integrated-4.0" path="*.ashx" verb="GET,HEAD,POST,DEBUG" type="System.Web.UI.SimpleHandlerFactory" preCondition="integratedMode,runtimeVersionv4.0" />
        <add name="WebServiceHandlerFactory-Integrated-4.0" path="*.asmx" verb="GET,HEAD,POST,DEBUG" type="System.Web.Script.Services.ScriptHandlerFactory, System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" preCondition="integratedMode,runtimeVersionv4.0" />
        <add name="svc-Integrated-4.0" path="*.svc" verb="*" type="System.ServiceModel.Activation.ServiceHttpHandlerFactory, System.ServiceModel.Activation, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" preCondition="integratedMode,runtimeVersionv4.0" />
        <add name="wildcard" path="*" verb="*" type="EPiServer.Web.StaticFileHandler, EPiServer.Framework.AspNet" />
      </handlers>
    </system.webServer>
  </location>
[assembly: OwinStartup(typeof(Startup))]
...
// necessary to get HttpContext to work in SecurityTokenValidated
app.Use((context, next) =>
{
    var httpContext = context.Get<HttpContextBase>(typeof(HttpContextBase).FullName);
    httpContext.SetSessionStateBehavior(SessionStateBehavior.Required);
    return next();
});

app.UseStageMarker(PipelineStage.MapHandler);

// must come AFTER the above
app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
app.UseCookieAuthentication(new CookieAuthenticationOptions());

ConfigureOrgUserAuthentication(app);
ConfigureOptiBackendAuthentication(app);

app.Map(AzureADB2CSettings.StorefrontLoginPath, map =>
{
    map.Run(context =>
    {
        var authenticationProperties = new AuthenticationProperties();

        var redirectUrl = context.Request.Query.GetValues("returnUrl");
        if (redirectUrl != null)
        authenticationProperties.RedirectUri = redirectUrl[0];

        context.Authentication.Challenge(authenticationProperties, AuthenticationType.Storefront);

        return Task.CompletedTask;
    });
});

app.Map(AzureADB2CSettings.StorefrontPasswordResetPath, map =>
{
    map.Run(context =>
    {
        context.Set("Policy", AzureADB2CSettings.ResetPasswordPolicyId);

        var authenticationProperties = new AuthenticationProperties();

        var redirectUrl = context.Request.Query.GetValues("returnUrl");
        if (redirectUrl != null)
                authenticationProperties.RedirectUri = redirectUrl[0];
        else
                authenticationProperties.RedirectUri = AzureADB2CSettings.StorefrontAccountInformationPath;

        context.Authentication.Challenge(authenticationProperties, AuthenticationType.Storefront);

        return Task.CompletedTask;
    });
});

app.Map(AzureADB2CSettings.StorefrontLogoutPath, map =>
{
    map.Run(context =>
    {
        context.Authentication?.SignOut(CookieAuthenticationDefaults.AuthenticationType, AuthenticationType.Storefront);

        _userService.SignOut();

        return Task.CompletedTask;
    });
});

//app.Map("/episerver", map =>
//{
//    map.Run(context =>
//    {
//        context.Authentication.Challenge(new AuthenticationProperties(), AuthenticationType.Cms);

//        return Task.CompletedTask;
//    });
//});

app.Map(ConfigurationManager.AppSettings["AAD.LoginPath"], map =>
{
    map.Run(context =>
    {
        var authenticationProperties = new AuthenticationProperties { 
        RedirectUri = "/episerver"
        };

        context.Authentication.Challenge(authenticationProperties, AuthenticationType.Cms);

        return Task.CompletedTask;
    });
});

app.Map(ConfigurationManager.AppSettings["AAD.LogoutPath"], map =>
{
    map.Run(context =>
    {
        context.Authentication?.SignOut(CookieAuthenticationDefaults.AuthenticationType, AuthenticationType.Cms);

        return Task.CompletedTask;
    });
});

AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;

        private void ConfigureOrgUserAuthentication(IAppBuilder app)
        {
            var clientId = AzureADB2CSettings.ClientId;
            var authority = $"https://{AzureADB2CSettings.TenantName}.b2clogin.com/{AzureADB2CSettings.Tenant}/{AzureADB2CSettings.SignUpSignInPolicyId}/v2.0/";
            
            if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(authority))
                return;

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions()
            {
                AuthenticationType = AuthenticationType.Storefront,
                ClientId = clientId,
                Authority = authority,
                SignInAsAuthenticationType = AuthenticationType.Storefront,
                Scope = OpenIdConnectScopes.OpenId,
                ResponseType = OpenIdConnectResponseTypes.CodeIdToken,
                RedirectUri = AzureADB2CSettings.RedirectUri,
                PostLogoutRedirectUri = AzureADB2CSettings.PostLogoutRedirectUri,
                TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = false,
                    NameClaimType = "name"
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleAuthenticationFailed(context),
                    AuthorizationCodeReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleAuthorizationCodeReceived(context),
                    MessageReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleMessageReceived(context),
                    RedirectToIdentityProvider = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleRedirectToIdentityProvider(context),
                    SecurityTokenReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleSecurityTokenReceived(context),
                    SecurityTokenValidated = async (context) => await OrgUserSecurityTokenValidated(context),
                }
            });
        }

        private void ConfigureOptiBackendAuthentication(IAppBuilder app)
        {
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions()
            {
                AuthenticationType = AuthenticationType.Cms,
                ClientId = ConfigurationManager.AppSettings["AAD.ClientId"],
                Authority = ConfigurationManager.AppSettings["AAD.AADAuthority"],
                RedirectUri = ConfigurationManager.AppSettings["AAD.RedirectUri"],
                PostLogoutRedirectUri = ConfigurationManager.AppSettings["AAD.PostLogoutRedirectUri"],
                SignInAsAuthenticationType = AuthenticationType.Cms,
                TokenValidationParameters = new TokenValidationParameters
                {
                    NameClaimType = "preferred_username",
                    RoleClaimType = ClaimTypes.Role
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);

                        return Task.CompletedTask;
                    },
                    RedirectToIdentityProvider = context =>
                    {
                        HandleMultiSiteReturnUrl(context);

                        if (context.OwinContext.Response.StatusCode == 401 &&
                            context.OwinContext.Authentication.User.Identity.IsAuthenticated)
                        {
                            context.OwinContext.Response.StatusCode = 403;
                            context.HandleResponse();
                        }

                        if (context.OwinContext.Response.StatusCode == 401 &&
                            IsXhrRequest(context.OwinContext.Request))
                            context.HandleResponse();

                        return Task.CompletedTask;
                    },
                    SecurityTokenValidated = OnSecurityTokenValidated
                }
            });
        }

Solution

  • Alright - figured out the answer.

    The first piece of the puzzle was in the <pages> section of <location path="episerver"> - I had to set validateRequest="false" in order to prevent the site from using its native validation mechanisms, and then remove the <authorization> section to prevent it from using its native authorization mechanisms.

    Once that was done, I was able to capture the requests against /episerver using a traditional IAppBuilder.Map. From there, I modified the approach to instead use MapWhen, and captured requests against /episerver that were either unauthenticated, or were authenticated without the correct claims.

    At that point I used IOwinContext.Challenge to direct the user to Azure AD for authentication, and then when they returned, they were able to access the back-end properly.

    EDIT

    Here's the complete(ish) code

    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            // necessary to get HttpContext to work in SecurityTokenValidated
            app.Use((context, next) =>
            {
                var httpContext = context.Get<HttpContextBase>(typeof(HttpContextBase).FullName);
                httpContext.SetSessionStateBehavior(SessionStateBehavior.Required);
                return next();
            });
    
            app.UseStageMarker(PipelineStage.MapHandler);
    
            // must come AFTER the above
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
    
            ConfigureOrgUserAuthentication(app);
            ConfigureOptiBackendAuthentication(app);
    
            app.MapWhen(ctx => FrontendNeedsAuthentication(ctx, _aadb2cSettings.StorefrontLoginPath), map =>
            {
                map.Run(async context =>
                {
                    var authenticationProperties = new AuthenticationProperties();
    
                    var redirectUrl = context.Request.Query.GetValues("returnUrl");
                    if (redirectUrl != null)
                        authenticationProperties.RedirectUri = redirectUrl[0];
    
                    context.Authentication.Challenge(authenticationProperties, AuthenticationType.Storefront);
                });
            });
    
            app.MapWhen(ctx => FrontendNeedsAuthentication(ctx, _aadb2cSettings.StorefrontPasswordResetPath), map =>
            {
                map.Run(context =>
                {
                    context.Set("Policy", _aadb2cSettings.ResetPasswordPolicyId);
    
                    var authenticationProperties = new AuthenticationProperties();
    
                    var redirectUrl = context.Request.Query.GetValues("returnUrl");
                    if (redirectUrl != null)
                        authenticationProperties.RedirectUri = redirectUrl[0];
                    else
                        authenticationProperties.RedirectUri = _aadb2cSettings.StorefrontAccountInformationPath;
    
                    context.Authentication.Challenge(authenticationProperties, AuthenticationType.Storefront);
    
                    return Task.CompletedTask;
                });
            });
    
            app.Map(_aadb2cSettings.StorefrontLogoutPath, map =>
            {
                map.Run(context =>
                {
                    _userService.SignOut();
    
                    return Task.CompletedTask;
                });
            });
    
            app.MapWhen(ctx => BackendNeedsAuthentication(ctx), map =>
            {
                map.Run(context =>
                {
                    context.Authentication.Challenge(new AuthenticationProperties(), AuthenticationType.Cms);
    
                    return Task.CompletedTask;
                });
            });
    
            app.Map(_aadSettings.LoginPath, map =>
            {
                map.Run(context =>
                {
                    var authenticationProperties = new AuthenticationProperties { 
                        RedirectUri = "/episerver"
                    };
    
                    context.Authentication.Challenge(authenticationProperties, AuthenticationType.Cms);
    
                    return Task.CompletedTask;
                });
            });
    
            app.Map(_aadSettings.LogoutPath, map =>
            {
                map.Run(context =>
                {
                    context.Authentication?.SignOut(CookieAuthenticationDefaults.AuthenticationType, AuthenticationType.Cms);
    
                    return Task.CompletedTask;
                });
            });
    
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
        }
    
        private void ConfigureOrgUserAuthentication(IAppBuilder app)
        {
            var domain = _aadb2cSettings.CustomDomain ?? $"{_aadb2cSettings.TenantName}.b2clogin.com";
            var clientId = _aadb2cSettings.ClientId;
            var authority = $"https://{domain}/{_aadb2cSettings.Tenant}/{_aadb2cSettings.SignUpSignInPolicyId}/v2.0/";
                
            if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(authority))
                return;
    
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions()
            {
                AuthenticationType = AuthenticationType.Storefront,
                ClientId = clientId,
                Authority = authority,
                SignInAsAuthenticationType = AuthenticationType.Storefront,
                Scope = OpenIdConnectScopes.OpenId,
                ResponseType = OpenIdConnectResponseTypes.CodeIdToken,
                RedirectUri = _aadb2cSettings.StorefrontRedirectUri,
                PostLogoutRedirectUri = _aadb2cSettings.StorefrontPostLogoutRedirectUri,
                ProtocolValidator = new OpenIdConnectProtocolValidator()
                {
                    RequireNonce = false
                },
                TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = false,
                    NameClaimType = "name"
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = async (context) => await OrgUserAuthenticationFailed(context),
                    AuthorizationCodeReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleAuthorizationCodeReceived(context),
                    MessageReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleMessageReceived(context),
                    RedirectToIdentityProvider = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleRedirectToIdentityProvider(context),
                    SecurityTokenReceived = async (context) => await _openIdConnectAuthenticationNotificationsService.HandleSecurityTokenReceived(context),
                    SecurityTokenValidated = async (context) => await OrgUserSecurityTokenValidated(context),
                }
            });
        }
    
        private void ConfigureOptiBackendAuthentication(IAppBuilder app)
        {
            var AADRedirectUri = Settings.Instance.EnableScheduler ?
                (_aadSettings.SchedulerRedirectUri ?? ConfigurationManager.AppSettings["AAD.AppScheduler.RedirectUri"]) :
                _aadSettings.RedirectUri;
    
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions()
            {
                AuthenticationType = AuthenticationType.Cms,
                ClientId = _aadSettings.ClientId,
                Authority = _aadSettings.Authority,
                RedirectUri = AADRedirectUri,
                PostLogoutRedirectUri = _aadSettings.PostLogoutRedirectUri,
                SignInAsAuthenticationType = AuthenticationType.Cms,
                TokenValidationParameters = new TokenValidationParameters
                {
                    //NameClaimType = "preferred_username",
                    RoleClaimType = ClaimTypes.Role
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);
    
                        return Task.CompletedTask;
                    },
                    RedirectToIdentityProvider = context =>
                    {
                        HandleMultiSiteReturnUrl(context);
    
                        if (context.OwinContext.Response.StatusCode == 401 &&
                            context.OwinContext.Authentication.User.Identity.IsAuthenticated &&
                            !HasBackendClaim(context.OwinContext.Authentication.User.Identity as ClaimsIdentity))
                        {
                            context.OwinContext.Response.StatusCode = 403;
                            context.HandleResponse();
                        }
    
                        if (context.OwinContext.Response.StatusCode == 401 &&
                            IsXhrRequest(context.OwinContext.Request))
                            context.HandleResponse();
    
                        return Task.CompletedTask;
                    },
                    SecurityTokenValidated = OnSecurityTokenValidated
                }
            });
        }
    
        private async Task OrgUserAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> ctx)
        {
            var authenticationProperties = new AuthenticationProperties
            {
                RedirectUri = $"{EPiServer.Web.SiteDefinition.Current.SiteUrl}/login/handleloginfailed?returnUrl=/"
            };
    
            ctx.OwinContext.Authentication?.SignOut(authenticationProperties, CookieAuthenticationDefaults.AuthenticationType, AuthenticationType.Storefront);
        }
    
        private async Task OrgUserSecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> ctx)
        {
            // skip this for reset password, because we do't need to renew the login
            if (!string.Equals(ctx.AuthenticationTicket.Identity.GetTfpClaim(), _aadb2cSettings.ResetPasswordPolicyId, StringComparison.OrdinalIgnoreCase))
            {
                // stuff
            }
        }
    
        private async Task OnSecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> ctx)
        {
            var synchronizingUserService = ServiceLocator.Current.GetInstance<ISynchronizingUserService>();
    
            await synchronizingUserService.SynchronizeAsync(ctx.AuthenticationTicket.Identity);
        }
    
        private static bool IsXhrRequest(IOwinRequest 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");
        }
    
        private void HandleMultiSiteReturnUrl(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
        {
            if (context.ProtocolMessage.RedirectUri == null)
            {
                var currentUrl = EPiServer.Web.SiteDefinition.Current.SiteUrl;
                context.ProtocolMessage.RedirectUri = new UriBuilder(currentUrl.Scheme,
                                                                        currentUrl.Host,
                                                                        currentUrl.Port,
                                                                        HttpContext.Current.Request.Url.AbsolutePath).ToString();
            }
        }
    
        private bool FrontendNeedsAuthentication(IOwinContext ctx, string storefrontPath)
        {
            bool isStorefrontPath = ctx.Request.Path.Value.ToLower().StartsWith(storefrontPath);
            if (!isStorefrontPath)
                return false;
            else if (ctx.Request.ContentType == "application/csp-report")
                return false;
            else if (ctx.Request.Method.ToLower() == "options")
                return false;
            else
                return true;
        }
    
        private bool BackendNeedsAuthentication(IOwinContext ctx)
        {
            bool isAuthenticated = ctx.Authentication.User.Identity.IsAuthenticated;
            bool isEpiserverPath = ctx.Request.Path.Value.ToLower().StartsWith("/episerver");
            if (!isEpiserverPath)
                return false;
            else if (!isAuthenticated || !(ctx.Authentication.User.Identity is ClaimsIdentity claimsIdentity))
                return true;
            else
                return HasBackendClaim(claimsIdentity);
        }
    
        private bool HasBackendClaim(ClaimsIdentity claimsIdentity)
        {
            return !claimsIdentity?.Claims?.Any(claim => claim.Type == "aud" &&
                                                            claim.Value == _aadSettings.ClientId) ?? false;
        }
    }