Add an Epi site to the IdentityServer4 combined quickstart using OIDC

IdentityServer4 is now available for and aligned with ASP.NET Core 2.0 - with some breaking changes. They've also added a combined quickstart that makes it's a lot faster to accomplish what I did earlier in my proof-of-concept post using the 1.x version.

I've also gained some experience since then so this is a better starting point and a better practice for OpenID Connect in general. Some adjustments and update notes has been added to the old post to avoid contradictions.

As a quick first step; create an Episerver Alloy web site and note which localhost URL and port it got.

Modifications to IdentityServer4's combined ASP.NET Identity and EF quickstart source

Clone or download IdentityServer4.Samples from Github, checkout the branch "release" and copy Quickstarts => Combined_AspNetIdentity_and_EntityFrameworkStorage => src => IdentityServerWithAspIdAndEF to a folder of your choice. I've seen a Path too long error here so keep the path short.

Open the csproj and edit the connection string in appsettings.json to your SQL Server instance.

In Config.cs add email and roles as an IdentityResource. My list looks like this now.

return new List<IdentityResource>
{
    new IdentityResources.OpenId(),
    new IdentityResources.Profile(),
    new IdentityResources.Email(),
    new IdentityResource
            {
                Name = "roles",
                DisplayName = "Roles",
                Description = "Allow the service access to your user roles.",
                UserClaims = new[] { JwtClaimTypes.Role },
                ShowInDiscoveryDocument = true,
                Required = true
            }
};

The OWIN OpenID Connect package is pretty lame and lack many parts of the OpenID Connect spec. You can still accomplish backchannel requests for userinfo and refreshtoken but you need to code it by hand which I will show later.

Implicit flow is used in the OpenID Connect part of Epi's documentation and my first post but as concluded by others hybrid flow with "code id_token" is more secure. Some quotes:

Actually, only the implicit flow (id_token) is officially supported, and you have to use the response_mode=form_post extension. Trying to use the authorization code flow will simply result in an exception being thrown during the callback, because it won't be able to extract the (missing) id_token from the authentication response.

Though not directly supported, you can also use the hybrid flow (code + id_token (+ token)), but it's up to you to implement the token request part.

A very competent answer on Stack Overflow

That’s the reason why the current consensus is, that an authorization code based flow gives you “a bit more” security than implicit.

Whenever you think about using authorization code flow – rather use hybrid flow. This gives you a verifiable token first before you make additional roundtrips.

Dominick Baier: Which OpenID Connect/OAuth 2.0 Flow is the right One?

A much more elegant and complete OpenID Connect implementation already exists for Core as you can see in the MvcClient directory in the quickstart.

In Config.cs, add this client for the Epi site. As you can see it follows most of the configuration for the MvcClient in the quickstart.

// Epi client
new Client
{
    ClientId = "epi",
    ClientName = "Epi Client",
    AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,

    RequireConsent = true,

    ClientSecrets =
    {
        new Secret("secret".Sha256())
    },

    // Check what localhost URL the Epi site got when installing
    RedirectUris = { "http://localhost:53004/signin-oidc" },
    PostLogoutRedirectUris = { "http://localhost:53004/signout-callback-oidc" },

    AllowedScopes =
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        IdentityServerConstants.StandardScopes.Email,
        "roles",
        "api1"
    },

    AllowOfflineAccess = true
}

Switch to SeedData.cs and add two roles to Bob Smith. If he is not still in the quickstart code my first EnsureSeedData method looks like this.

public static void EnsureSeedData(IServiceProvider serviceProvider)
{
    Console.WriteLine("Seeding database...");

    using (var scope = serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope())
    {
        scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();

        {
            var context = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
            context.Database.Migrate();
            EnsureSeedData(context);
        }

        {
            var context = scope.ServiceProvider.GetService<ApplicationDbContext>();
            context.Database.Migrate();

            var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();

            var bob = userMgr.FindByNameAsync("bob").Result;
            if (bob == null)
            {
                bob = new ApplicationUser
                {
                    UserName = "bobsmith@email.com",
                    Email = "bobsmith@email.com",
                    EmailConfirmed = true
                };
                var result = userMgr.CreateAsync(bob, "Pass123$").Result;
                if (!result.Succeeded)
                {
                    throw new Exception(result.Errors.First().Description);
                }

                result = userMgr.AddClaimsAsync(bob, new Claim[]{
                new Claim(JwtClaimTypes.Name, "Bob Smith"),
                new Claim(JwtClaimTypes.GivenName, "Bob"),
                new Claim(JwtClaimTypes.FamilyName, "Smith"),
                new Claim(JwtClaimTypes.WebSite, "http://bob.com"),
                new Claim(JwtClaimTypes.Role, "DatabaseUsers"),
                new Claim(JwtClaimTypes.Role, "WebAdmins") // Testing Epi access
            }).Result;
                if (!result.Succeeded)
                {
                    throw new Exception(result.Errors.First().Description);
                }
                Console.WriteLine("bob created");
            }
            else
            {
                Console.WriteLine("bob already exists");
            }
        }
    }

    Console.WriteLine("Done seeding database.");
    Console.WriteLine();
}

Now you are ready to launch the IdentityServer4 app. First time run it with the /seed parameter, this will call Migrate() on the contexts and add the initial config data.

dotnet run /seed

For manual handling you can instead use the console from the csproj path and run dotnet ef dbcontext list to get a listing. Then in that case; add them manually. Could be good to know how:

dotnet ef database update --context ApplicationDbContext
dotnet ef database update --context ConfigurationDbContext
dotnet ef database update --context PersistedGrantDbContext

Try to login using bobsmith@email.com / Pass123$ and click About in the header to list the claims.

You can also take a look at http://localhost:5000/.well-known/openid-configuration and check that "roles" is a scope.

With the quickstart version I started from it's also possible to do a Google account signin.

Changes to the Episerver Alloy site

There's a bug included in Microsoft.Owin.Security.OpenIdConnect that can cause some annoying issues around cookies so it's smart to install this small package.

Install-Package Kentor.OwinCookieSaver

Also install this package to handle the backchannel calls in a nicer fashion.

Install-Package IdentityModel

Then follow the steps in Integrate Azure AD using OpenID Connect in the CMS documentation about Web.config and NuGet packages.

I add an action to PageControllerBase that triggers a challenge which redirects to startpage:

public ActionResult AuthenticateOidc()
{
    if (User.Identity.IsAuthenticated)
    {
        return RedirectToAction("Index");
    }

    return new HttpStatusCodeResult(401);
}

Then use this Startup.cs that should be working after you modify the port for the Alloy URLs in it.

using EPiServer.Security;
using EPiServer.ServiceLocation;
using IdentityModel.Client;
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin;
using Microsoft.Owin.Extensions;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
using System;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

[assembly: OwinStartup(typeof(EpiserverSiteWithQuickstart.Startup))]

namespace EpiserverSiteWithQuickstart
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            JwtSecurityTokenHandler.InboundClaimTypeMap.Clear();
            app.UseKentorOwinCookieSaver();

            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

            app.UseCookieAuthentication(new CookieAuthenticationOptions());

            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = "epi",
                ClientSecret = "secret",
                Authority = "http://localhost:5000/",
                PostLogoutRedirectUri = "http://localhost:53004/signout-callback-oidc",
                Scope = "openid email profile roles offline_access api1",
                ResponseType = "code id_token",
                TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    NameClaimType = "sub",
                    RoleClaimType = "role"
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);
                        return Task.FromResult(0);
                    },

                    RedirectToIdentityProvider = context =>
                    {
                        // If signing out, add the id_token_hint
                        if (context.ProtocolMessage.RequestType ==
                                OpenIdConnectRequestType.LogoutRequest)
                        {
                            var idTokenHint = context.OwinContext.Authentication
                                 .User.FindFirst(OpenIdConnectParameterNames.IdToken);

                            if (idTokenHint != null)
                            {
                                context.ProtocolMessage.IdTokenHint = idTokenHint.Value;
                            }
                        }

                        context.ProtocolMessage.RedirectUri = "http://localhost:53004/signin-oidc";

                        // To avoid a redirect loop to the federation server send 403
                        // when user is authenticated but does not have access
                        if (context.OwinContext.Response.StatusCode == 401 &&
                            context.OwinContext.Authentication.User.Identity.IsAuthenticated)
                        {
                            context.OwinContext.Response.StatusCode = 403;
                            context.HandleResponse();
                        }

                        return Task.FromResult(0);
                    },

                    AuthorizationCodeReceived = async notification =>
                    {
                        // Backchannel calls are made from here
                        var configuration = await notification.Options.ConfigurationManager
                                 .GetConfigurationAsync(notification.Request.CallCancelled);

                        var tokenClient = new TokenClient(configuration.TokenEndpoint,
                                 notification.Options.ClientId, notification.Options.ClientSecret,
                                      AuthenticationStyle.PostValues);
                        var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(
                            notification.ProtocolMessage.Code,
                            "http://localhost:53004/signin-oidc",
                            cancellationToken: notification.Request.CallCancelled);

                        if (tokenResponse.IsError
                                || string.IsNullOrWhiteSpace(tokenResponse.AccessToken)
                                || string.IsNullOrWhiteSpace(tokenResponse.RefreshToken))
                        {
                            notification.HandleResponse();
                            notification.Response.Write("Error retrieving tokens.");
                            return;
                        }

                        var userInfoClient = new UserInfoClient(configuration.UserInfoEndpoint);
                        var userInfoResponse = await userInfoClient.GetAsync(tokenResponse.AccessToken);

                        if (userInfoResponse.IsError
                                || userInfoResponse.Claims.FirstOrDefault(x => x.Type == "sub")?.Value
                                    != notification.AuthenticationTicket.Identity.Claims
                                        .FirstOrDefault(x => x.Type == "sub")?.Value)
                        {
                            notification.HandleResponse();
                            notification.Response.Write("Error retrieving user info.");
                            return;
                        }

                        // Create new identity
                        var id = new ClaimsIdentity(notification.AuthenticationTicket
                                      .Identity.AuthenticationType);
                        id.AddClaims(userInfoResponse.Claims);
                        id.AddClaim(new Claim(
                                OpenIdConnectParameterNames.AccessToken,
                                tokenResponse.AccessToken));
                        id.AddClaim(new Claim(
                                "expires_at",
                                DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString()));
                        id.AddClaim(new Claim(
                                OpenIdConnectParameterNames.RefreshToken,
                                tokenResponse.RefreshToken));
                        id.AddClaim(new Claim(
                                OpenIdConnectParameterNames.IdToken,
                                notification.ProtocolMessage.IdToken));
                        id.AddClaim(new Claim(
                            "sid",
                            notification.AuthenticationTicket.Identity.FindFirst("sid").Value));
                        notification.AuthenticationTicket = new AuthenticationTicket(
                            new ClaimsIdentity(
                                id.Claims,
                                notification.AuthenticationTicket.Identity.AuthenticationType, "sub", "role"),
                                notification.AuthenticationTicket.Properties);

                        // Sync user and the roles to Episerver in the background
                        await ServiceLocator.Current.GetInstance<ISynchronizingUserService>()
                                .SynchronizeAsync(notification.AuthenticationTicket.Identity);
                    }
                }
            });
            app.UseStageMarker(PipelineStage.Authenticate);
            app.Map("/util/logout.aspx", map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    return Task.FromResult(0);
                });
            });
        }
    }
}

Compared to Epi's documentation this a bit leaner and the flow is as stated above considered more secure.

Here is one of the samples I looked at.

To test browse to /en/authenticateoidc/ which triggers a 401 response that sends you off to the IdentityServer4. Sign in as bobsmith@email.com and you should come back to the start page with admin access.

If you enter Epi you'll see that "sub" is the Name. To change it to something that's human readable you can switch "sub" in Startup.cs to "preferred_username" or "email". In my experience it's best to tie things to the most long-lived and fixed ID available no matter how it looks. Especially if plan is to build functionality and store data related to a user.

The flow uses a form post to send the id_token. If you log the traffic or disable javascript it's easy to see it.

<form method='post' action='http://localhost:53004/signin-oidc'>
<input type='hidden' name='code' value='ed439042 ..' />
<input type='hidden' name='id_token' value='eyJhbGciOiJSU ..' />
<input type='hidden' name='scope' value='openid email profile roles api1 offline_access' />
<input type='hidden' name='state' value='OpenIdConnect.AuthenticationProperties=GnA-KW3YYNKFvh ..' />
<input type='hidden' name='session_state' value='96V-KzO1A ..' />
</form>

To analyze what's in it you can just paste the id_token into the jwt.io debugger.

Now you can start the other apps in the quickstart and feel all Microservice'y when you move between IdentityServer4-connected apps and call the API app on behalf of the signed in user etc.

When you need a new access_token you also have a refresh_token tucked away in the authentication ticket ready to be used.

Published and tagged with these categories: Episerver, ASP.NET