A day in the life of an Optimizely Developer - Implementing Identity Server 4 and ASP.Net Identity
Hello and welcome to another instalment of A Day In The Life Of An Optimizely Developer.
Recently on a CMS 12 build that I was doing for a client I was faced with the challenge of implementing Identity Server 4 for the website user login, whilst still retaining the default ASP.Net Identity login for Optimizely CMS. At the outset this sounded quite easy, but reality showed me otherwise, therefore I have written this blog post to provide reader's with an overview of how you setup two different authentication schemes at the same time.
In this implementation, I needed to integrate with a 3rd party SSO solution that was running on Identity Server 4. For those not aware of Identity Server, combining OpenID Connect and OAuth 2.0 is considered one of the best approaches to securing modern applications and Identity Server 4 is an implementation of these two protocols. I won't delve too deep into what Identity Server 4 is and how it is used, I have added some links in the reference section at the end of this blog post that provide more information if you fancy some bedtime reading.
Before we start with the implementation we first need to add a reference to the Microsoft.AspNetCore.Authentication.OpenIdConnect NuGet package, this allows us to use OpenIdConnect for our implementation with Identity Server 4. The official documentation describes OpenID Connect as:
"OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. It allows Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about the End-User in an interoperable and REST-like manner.
OpenID Connect allows clients of all types, including Web-based, mobile, and JavaScript clients, to request and receive information about authenticated sessions and end-users. The specification suite is extensible, allowing participants to use optional features such as encryption of identity data, discovery of OpenID Providers, and logout, when it makes sense for them."
So moving onto the implementation of this, the first step is to add a new class called "UserAuthenticationServiceExtensions" which will act as the middleware that you inject in the Startup.cs file (detailed later). We first need to add the constructor for the AddUserAuthentication class, this needs to call the AddCmsAspNetIdentity method that adds the authentication scheme for the CMS logins as Asp.Net Identity, the next call is to a private method AddIdentityServer that adds the OpenID Connect and IdentityServer scheme functionality.
public static IServiceCollection AddUserAuthentication(
this IServiceCollection services,
IWebHostEnvironment environment,
IConfiguration configuration)
{
services.AddCmsAspNetIdentity<ApplicationUser>();
services.AddIdentityServer(configuration);
return services;
}
Within the AddIdentityServer method we first need to a add couple of constants that define both an Authentication and Challenge scheme to be used in the Identity Server implementation utilising OpenID Connect.
private const string AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme; // CookieAuthenticationDefaults.AuthenticationScheme =
"Cookies"
private const string ChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; // OpenIdConnectDefaults.AuthenticationScheme = "OpenIdConnect"
The next step is to add the call to the AddAuthentication method to define the default Authentication and Challenge schemes. We can see that the default challenge scheme is set to "policy-scheme", this is a custom scheme that is defined later in the class and is the important part for ensuring the application can determine when the two different authentication schemes are to be used.
services.AddAuthentication(options =>
{
options.DefaultScheme = AuthenticationScheme;
options.DefaultChallengeScheme = "policy-scheme";
})
We next need to add the two cookies required for both authentication schemes. The first cookie is the one that will be utilised by the Asp.Net Identity authentication scheme and the second cookie is the one that will be used by the OpenID Connect authentications 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)
The next part of the implementation is to add the OpenID Connect specific functionality calling the "AddOpenIdConnect" method. I have just highlighted the important parts to call out of this functionality as the rest is pretty standard implementation and also depends very much on how your Identity Server 4 client has been setup.
So in this call we can see that we pass in the name of the authentication scheme we want to associate with OpenID Connect, in this case "IdentityServer", next we define the default Sign In and Sign Out scheme which is set to the ChallengeScheme constant we created earlier. We next specify the response type, in this case the Identity Server client has been setup for AuthorizationCode flow (Identity Server has five different flows (more information on flows in the links at the end of this blog). The callback path is also set and for OpenID Connect should be set to "/signin-oidc". UsePkce is set to false in this case, but this depends on how your client has been setup (Pkce stands for Proof Key for Code Exchange standard and is detailed in the references section)
.AddOpenIdConnect("IdentityServer", 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;
....
})
The final part and indeed the most important part is to define the custom policy scheme that we referenced earlier. We call the "AddPolicyScheme" method for this, passing in the name of the new scheme. Next we need to define the paths that determine which authentication scheme should be used. If the path starts with "/episerver" or "/util" we know this is a user trying to login to the CMS and therefore we return the scheme that is used by Asp.Net Identity, in this case "Identity.Application", for all other paths we just return "IdentityServer" which in this case is the scheme used by OpenId Connect.
.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; // IdentityConstants.ApplicationScheme = "Identity.Application"
}
return "IdentityServer";
};
});
The full code of the UserAuthentication class is provided below:
using System.Text;
using EPiServer.Cms.UI.AspNetIdentity;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
public static class UserAuthenticationServiceExtensions
{
private const string AuthenticationScheme = CookieAuthenticationDefaults.AuthenticationScheme; // CookieAuthenticationDefaults.AuthenticationScheme =
"Cookies"
private const string ChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; // OpenIdConnectDefaults.AuthenticationScheme = "OpenIdConnect"
/// <summary>
/// Sets up authentication based on one of the following schemes:
/// Optimizely CMS Identities for the CMS login using AspNet Identity.
/// 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.AddCmsAspNetIdentity<ApplicationUser>();
services.AddIdentityServer(configuration);
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("IdentityServer", 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.Scope.Add("xxx");
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; // IdentityConstants.ApplicationScheme = "Identity.Application"
}
return "IdentityServer";
};
});
}
}
The next step is to add the new configuration section to our appsettings.json file, in this case we have:
- Authority - this is the Identity Server 4 URI
- Client Id - this is the id of the client that has been setup within Identity Server 4
- Client Secret - this is the secret of the client that has been setup within Identity Server 4
- RequireHttpsMetadata - this determines if http or https should be required, generally this should be set to true
"IdentityServerSettings": {
"Authority": "https://xxx.xx.com/identity",
"ClientId": "xxx",
"ClientSecret": "xxx",
"RequireHttpsMetadata": true
}
Next we need to register the new user authentication middleware in the ConfigureServices method of the Startup.cs file:
services.AddUserAuthentication(_webHostingEnvironment, _configuration)
The following also needs adding in the Configure method of the Startup.cs file:
app.UseAuthentication();
app.UseAuthorization();
Now we need to implement the authentication controller, registering the Login and Logout routes that the user's of the website are to be redirected to.
For the login method, we need to decorate it with an Authorize attribute, this needs to return a ChallengeResult that is an ActionResult that on execution invokes HttpContext.ChallengeAsync for the challenge scheme(s) passed in which in this case is a single scheme called "IdentityServer".
In terms of the logout method, we need to ensure we call the SignOutAsync method of the current HttpContext passing in the Authentication Scheme we want to logout, in this case the "OpenIdConnect" scheme. We then redirect back to the website homepage.
using System.Globalization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
public sealed class AuthenticationController
{
[Authorize(AuthenticationSchemes = "IdentityServer")]
[HttpGet]
[Route("/login")]
public IActionResult? Login()
{
return new ChallengeResult("IdentityServer", new AuthenticationProperties { RedirectUri = "/" });
}
[HttpGet]
[Route("/logout")]
public IActionResult? Logout()
{
HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); // OpenIdConnectDefaults.AuthenticationScheme = "OpenIdConnect"
return Redirect("/");
}
}
Finally (and this is one part that confused me for a while) is providing the ability for both anonymous and logged in user's access to a page, to do this we needed to add the same Authorize attribute as for the login method but also add the "AllowAnonymous" attribute. For pages that we don't want non-logged in users to access, we can simply omit this attribute.
[Authorize(AuthenticationSchemes = "IdentityServer")]
[AllowAnonymous]
I hope this blog post provides an insight into how implementing login for multiple authentication schemes can be achieved, I know in this case it is heavily leaning towards Identity Server 4 but changing the provider to something like Azure AD should be relatively simple, both for the website user login and/or the CMS login.
References
https://identityserver4.readthedocs.io/en/latest/intro/terminology.html
https://identityserver4.readthedocs.io/en/latest/topics/clients.html
https://docs.wso2.com/display/IS530/Authorization+Code+Grant
https://docs.developers.optimizely.com/content-cloud/v12.0.0-content-cloud/docs/mixed-mode-authentication
https://docs.developers.optimizely.com/content-cloud/v12.0.0-content-cloud/docs/integrate-azure-ad-using-openid-connect
https://gist.github.com/jawadatgithub/638c11f08ecc0d76b05c
https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-proof-key-for-code-exchange-pkce
Comments