Hello :-) Have you found the solution? I'm struggling with the same issue, the events are not triggered actually!
I have got this working with Identity Server for the website user's logins and the standard ASPNet Identity for CMS logins, see my extension class below:
public static class UserAuthenticationServiceExtensions
{
private const string AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme;
private const string ChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
/// <summary>
/// Sets up authentication based on one of the following schemes:
/// Optimizely CMS Identities for the CMS login.
/// Identity Server using Open ID connect for front-end user login.
/// </summary>
/// <param name="services"></param>
/// <param name="environment"></param>
/// <param name="configuration"></param>
/// <returns></returns>
public static IServiceCollection AddUserAuthentication(
this IServiceCollection services,
IWebHostEnvironment environment,
IConfiguration configuration)
{
services.AddIdentityServer(configuration);
services.AddCmsAspNetIdentity<ApplicationUser>();
return services;
}
/// <summary>
/// Sets up authentication based on Identity Server 4 using Open ID Connect
/// </summary>
/// <param name="services"></param>
/// <param name="configuration"></param>
/// <returns></returns>
public static void AddIdentityServer(this IServiceCollection services, IConfiguration configuration)
{
var identityServerSettings = configuration.GetSection(nameof(IdentityServerSettings)).Get<IdentityServerSettings>();
var authority = identityServerSettings?.Authority ?? string.Empty;
_ = bool.TryParse(identityServerSettings?.RequireHttpsMetadata ?? "true", out bool requireHttpsMetadata);
var clientId = identityServerSettings?.ClientId ?? string.Empty;
var clientSecret = identityServerSettings?.ClientSecret ?? string.Empty;
services.AddAuthentication(options =>
{
options.DefaultScheme = AuthenticationScheme;
options.DefaultChallengeScheme = "policy-scheme";
})
.AddCookie(AuthenticationScheme, options =>
{
// Defines a path to redirect the user to if they don't have access to a page.
// This page should return a 200 response so as to not cause authentication loops.
options.AccessDeniedPath = new PathString("/no-access");
})
.AddCookie(ChallengeScheme)
.AddOpenIdConnect(ConfigConstants.IdentityServerAuthenticationScheme, options =>
{
options.SignInScheme = ChallengeScheme;
options.SignOutScheme = ChallengeScheme;
options.ResponseType = OpenIdConnectResponseType.Code;
options.CallbackPath = "/signin-oidc";
options.UsePkce = false;
options.Authority = authority;
options.RequireHttpsMetadata = requireHttpsMetadata;
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.Scope.Clear();
options.Scope.Add(OpenIdConnectScope.OpenId);
options.MapInboundClaims = false;
options.Events.OnRedirectToIdentityProvider = context =>
{
// Prevent redirect loop
if (context.Response.StatusCode == 401)
{
context.HandleResponse();
}
if (context.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout)
{
var idTokenHint = context.HttpContext.User.FindFirst("id_token");
if (idTokenHint != null)
{
context.ProtocolMessage.IdTokenHint = idTokenHint.Value;
}
}
return Task.CompletedTask;
};
options.Events.OnAuthenticationFailed = async context =>
{
context.HandleResponse();
await context.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(context.Exception.Message));
};
})
.AddPolicyScheme("policy-scheme", null, options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (ctx.Request.Path.StartsWithSegments("/episerver", StringComparison.OrdinalIgnoreCase) ||
ctx.Request.Path.StartsWithSegments("/util", StringComparison.OrdinalIgnoreCase))
{
return IdentityConstants.ApplicationScheme;
}
return ConfigConstants.IdentityServerAuthenticationScheme;
};
});
}
You then need to ensure you decorate your page controllers with the following attributes:
[Authorize(AuthenticationSchemes = AuthSchemes, Roles = AuthRoles)]
[AllowAnonymous]
with the constants for the above being as follows:
private const string AuthSchemes = "IdentityServer,Identity.Application";
private const string AuthRoles = "CmsAdmins,CmsEditors,Everyone";
You should be able to adapt the above to your needs, however the important parts for you are ensuring you have two separate cookies created, and also to ensure you create a policy scheme which allows you to route to the correct authentication scheme.
Hope this helps a bit?
For me requiremens we slightly different:
I achieved this by following Graham's example with a couple of changes:
services.AddAuthentication(options =>
{
options.DefaultScheme = "policy-scheme";
options.DefaultAuthenticateScheme = "policy-scheme";
options.DefaultChallengeScheme = "policy-scheme";
})
.AddCookie()
.AddOpenIdConnect(
options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.ClientId = "####";
options.ClientSecret = "####";
options.Authority = "#####";
options.CallbackPath = "/callback";
options.ResponseType = "code";
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
RoleClaimType = ClaimTypes.Role,
NameClaimType = ClaimTypes.Email
};
options.Events.OnAuthenticationFailed = ctx =>
{
ctx.HandleResponse();
ctx.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(ctx.Exception.Message));
return Task.FromResult(0);
};
options.Events.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);
};
}).AddPolicyScheme("policy-scheme", null, options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (ctx.Request.Cookies.ContainsKey(".AspNetCore." + IdentityConstants.ApplicationScheme) ||
ctx.Request.Path.StartsWithSegments("/util", StringComparison.OrdinalIgnoreCase))
{
return IdentityConstants.ApplicationScheme;
}
return "azure";
};
});
Hi Levon
According to your requirement, you don't have to go with this complex set up with forward selector. By default, /util/login will use Asp.net Identity to authenticate. /episerver will use the scheme you set in the DefaultChallengeScheme. In other words, you just set DefaultChallengeScheme to "azure", everything will work as you expected.
Hi Vincent,
With DefaultChallengeScheme set to "azure", when I logged in to /util/login with Asp.net Identity, CMS app did not accept generated cookie, so I got into infinite loop of logging into Asp.net Identity. Same thing happened when DefaultChallengeScheme was set to IdentityConstants.ApplicationScheme, when I logged in into Microsoft Entra ID via /episerver I got into infinite loop of logging in. I need to be able to login to /episerver when Microsoft Entra ID is not working / offline. So I login to /util/login with Asp.net Identity and then can access /episerver without additional authentication.
I want to use OpenID Connect for all end users and use standard Episerver login for all editors. Is there any way to accomplish this in Optimizely 12?
This is my current setup. Now everyone is moving towards the external provider.
What I would like to achieve is that the url "/episerver/cms" uses the standard login.