IdentityServer4 with Episerver and OpenID Connect

Federated Security is really nice and you should recommend it everywhere. The new IdentityServer4 comes as a .NET Core package and is an interesting option since it, like earlier versions, is open source and free.

I've tested it out a little bit and have come up with an Alloy PoC using OpenID Connect between Epi and the IdentityServer app. ASP.NET Identity is used for "local" IdentityServer users and persisting of external users (Google and Facebook).

UPDATE: Be sure to read the more recent post using IdentityServer4 2.x with an Episerver site. It's easier to get up and running and is more secure.

The PoC is currently not as fully featured as for example the setup by Svein Aandahl on OAuth2.0 integration between IdentityServer3 and Episerver but it should serve as a good starting point and feedback driver for IdentityServer4 with Epi.

Starting off on a fresh Alloy site; follow the steps in Integrate Azure AD using OpenID Connect in the CMS documentation about Web.config and Startup.cs but skip everything below the "Adding application roles in Azure Active Directory" heading. This will get the OpenID Connect basics in place.

For IdentityServer4 endpoints we need to change the Startup class URL config a little bit. We also tweak the logout route and call our own sync service. This is what I ended up with.

The PostLogoutRedirectUri is the URL of the actual Epi site and the misnamed AadInstance URL is the IdentityServer4 .NET Core App we will setup shortly.

using Microsoft.Owin.Extensions;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Notifications;
using Microsoft.Owin.Security.OpenIdConnect;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Owin;
using System;
using System.IdentityModel.Tokens;
using System.Threading.Tasks;
using System.Web;
using EPiServer.ServiceLocation;

[assembly: OwinStartup(typeof(EpiserverSite.Startup))]
namespace EpiserverSite
{
    public class Startup
    {
        private static readonly string AadInstance = "http://localhost:5000/"; // Default dev setup

        private static readonly string ClientId = "epi";

        private static readonly string PostLogoutRedirectUri = "http://localhost:58218/";

        const string LogoutPath = "/util/logout.aspx";

        public void Configuration(IAppBuilder app)
        {
            JwtSecurityTokenHandler.InboundClaimTypeMap.Clear();
            app.UseKentorOwinCookieSaver();
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = ClientId,
                Authority = AadInstance,
                PostLogoutRedirectUri = PostLogoutRedirectUri,
                Scope = "openid email profile roles",
                ResponseType = "id_token",
                TokenValidationParameters = new System.IdentityModel.Tokens.TokenValidationParameters
                {
                    NameClaimType = "sub",
                    RoleClaimType = "role"
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);
                        return Task.FromResult(0);
                    },
                    RedirectToIdentityProvider = context =>
                    {
                        // Here you can change the return uri based on multisite
                        HandleMultiSitereturnUrl(context);

                        // 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);
                    },
                    SecurityTokenValidated = (ctx) =>
                    {
                        var redirectUri = new Uri(ctx.AuthenticationTicket.Properties.RedirectUri,
                           UriKind.RelativeOrAbsolute);

                        if (redirectUri.IsAbsoluteUri)
                        {
                            ctx.AuthenticationTicket.Properties.RedirectUri =
                                redirectUri.PathAndQuery;
                        }

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

        private void HandleMultiSitereturnUrl(
                RedirectToIdentityProviderNotification<Microsoft.IdentityModel
                    .Protocols.OpenIdConnectMessage,
                    OpenIdConnectAuthenticationOptions> context)
        {
            // here you change the context.ProtocolMessage.RedirectUri to corresponding siteurl
            // this is a sample of how to change redirecturi in the multi-tenant environment
            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();
            }
        }
    }
}

I failed to hook up my own implementation of ISynchronizingUserService. I need to reflect or ask around to find out why but this works for now with the above Startup.cs, just ignore the obsolete-warning.

namespace EpiserverSite
{
    using System.Security.Claims;
    using System.Threading.Tasks;

    using EPiServer.Security;

    public class IdentityServerSyncService : SynchronizingUserService
    {
        public override Task SynchronizeAsync(ClaimsIdentity identity)
        {
            // Transform the passed role claims to System.Security.Claims
            foreach (var claim in identity.Claims)
            {
                if (claim.Type == "role")
                {
                    identity.AddClaim(new Claim(ClaimTypes.Role, claim.Value));
                }
            }

            return base.SynchronizeAsync(identity);
        }
    }
}

To easier test and see what's going on I add an action to PageControllerBase to trigger the authentication redirect.

[Authorize]
public ActionResult Login()
{
    return Redirect("/");
}

Then change the Footer a little bit.

@if (Model.Layout.LoggedIn)
{
    <a href="/util/logout.aspx">@Html.Translate("/footer/logout")</a>
}
else
{
    if (!Model.Layout.IsInReadonlyMode)
    {
        <a href="/login/">@Html.Translate("/footer/login")</a>
    }
}

Render the claims up top in the _Root layout. This and the sync service above are pretty much off David Knipe's Auth0 blog posts.

@if (this.User != null && (this.User as ClaimsPrincipal) != null
       && (this.User as ClaimsPrincipal).Identity.IsAuthenticated)
{
    <div class="container">
        <table class="table table-striped table-condensed">
            <thead>
                <tr>
                    <th>Claims for "@((this.User as ClaimsPrincipal).Identity.Name)"</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var claim in (this.User as ClaimsPrincipal).Claims)
                {
                    <tr>
                        <td>
                            @claim.Type <br />
                            <strong>
                                @claim.Value
                            </strong>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    </div>
}

That's all in our Alloy Epi 10 site.

Moving on to the IdentityServer4 project. The documentation is really good even at this early stage. These are the pages that I've followed.

  1. IdentityServer4: Setup and overview
  2. IdentityServer4: Adding User Authentication with OpenID Connect
  3. IdentityServer4: Adding Support for External Authentication
  4. IdentityServer4: Using EntityFramework Core for configuration data
  5. Scott Brady: Getting Started with IdentityServer4 - a bit dated but the parts on the ASP.NET Core Identity integration, ApplicationDbContext, AccountsController modifications and the EF migrations are needed and didn't find better info elsewhere.

When done project.json should have the following dependencies and tools.

"dependencies": {
    "IdentityServer4": "1.0.0-rc3",
    "IdentityServer4.AspNetIdentity": "1.0.0-rc3",
    "IdentityServer4.EntityFramework": "1.0.0-rc3",
    "Microsoft.AspNetCore.Authentication": "1.1.0",
    "Microsoft.AspNetCore.Authentication.Cookies": "1.1.0",
    "Microsoft.AspNetCore.Authentication.Facebook": "1.1.0",
    "Microsoft.AspNetCore.Authentication.Google": "1.1.0",
    "Microsoft.AspNetCore.Diagnostics": "1.0.0",
    "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "1.1.0",
    "Microsoft.AspNetCore.Mvc": "1.0.0",
    "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0",
    "Microsoft.AspNetCore.Server.Kestrel": "1.0.1",
    "Microsoft.AspNetCore.StaticFiles": "1.0.0",
    "Microsoft.EntityFrameworkCore.SqlServer": "1.0.0",
    "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final",
    "Microsoft.Extensions.Logging.Console": "1.0.0",
    "Microsoft.NETCore.App": {
        "version": "1.0.1",
        "type": "platform"
    }
},

"tools": {
    "Microsoft.AspNetCore.Server.IISIntegration.Tools": "1.0.0-preview2-final",
    "Microsoft.EntityFrameworkCore.Tools": "1.0.0-preview2-final"
}

And the Startup class I'm running now looks like this.

namespace IdApp
{
    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Security.Claims;
    using System.Security.Cryptography.X509Certificates;

    using IdentityModel;

    using IdentityServer4;
    using IdentityServer4.EntityFramework.DbContexts;
    using IdentityServer4.EntityFramework.Mappers;
    using IdentityServer4.Models;

    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.Identity;
    using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Logging;

    public class Startup
    {
        private readonly IHostingEnvironment environment;

        public Startup(IHostingEnvironment env)
        {
            this.environment = env;
        }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            var connectionString =
               @"Data Source=.;Database=krompaco-identityserver;Integrated Security=True";
            var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

            services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
            services.AddIdentity<IdentityUser, IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();

            var cert = new X509Certificate2(Path.Combine(this.environment.ContentRootPath, "localhost.pfx"), "test");

            services.AddIdentityServer()
                 .AddSigningCredential(cert)
                 .AddAspNetIdentity<IdentityUser>()
                 .AddConfigurationStore(builder =>
                     builder.UseSqlServer(connectionString, options =>
                         options.MigrationsAssembly(migrationsAssembly)))
                 .AddOperationalStore(builder =>
                     builder.UseSqlServer(connectionString, options =>
                         options.MigrationsAssembly(migrationsAssembly)));
        }

        public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory)
        {
            loggerFactory.AddConsole();

            // This will do the initial DB population
            InitializeDatabase(app);

            app.UseDeveloperExceptionPage();

            app.UseIdentity();
            app.UseIdentityServer();

            // Cookie middleware for temporarily storing the outcome of the external authentication
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme,
                AutomaticAuthenticate = false,
                AutomaticChallenge = false
            });

            // Middleware for Google authentication (demo app from Github samples that work with localhost:5000)
            app.UseGoogleAuthentication(new GoogleOptions
            {
                AuthenticationScheme = "Google",
                SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme,
                ClientId = "434483408261-55tc8n0cs4ff1fe21ea8df2o443v2iuc.apps.googleusercontent.com",
                ClientSecret = "3gcoTrEDPPJ0ukn_aYYT6PWo"
            });

            // Facebook (Johan's developer app)
            app.UseFacebookAuthentication(new FacebookOptions
            {
                AuthenticationScheme = "Facebook",
                SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme,
                AppId = "some_numbers",
                AppSecret = "the_secret_phrase"
            });

            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }

        private void InitializeDatabase(IApplicationBuilder app)
        {
            // Migrate and setup some test data if empty database
            using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
            {
                serviceScope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>().Database.Migrate();

                var context = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
                context.Database.Migrate();

                if (!context.Clients.Any())
                {
                    var client = new Client
                                     {
                                         ClientId = "epi",
                                         ClientName = "Epi Client",
                                         AllowedGrantTypes = GrantTypes.List(GrantType.Implicit),
                                         RedirectUris = { "http://localhost:58218/", "http://localhost:58218/login/" },
                                         // Wildcards are not supported :(
                                         PostLogoutRedirectUris = { "http://localhost:58218/" },
                                         AllowedScopes =
                                             {
                                                 StandardScopes.OpenId.Name,
                                                 StandardScopes.Email.Name,
                                                 StandardScopes.Profile.Name,
                                                 StandardScopes.Roles.Name
                                             }
                                     };

                    context.Clients.Add(client.ToEntity());
                    context.SaveChanges();
                }

                var scopes = new List<Scope> { StandardScopes.OpenId, StandardScopes.Email, StandardScopes.Profile, StandardScopes.Roles };

                foreach (var scope in scopes)
                {
                    var existing = context.Scopes.FirstOrDefault(x => x.Name == scope.Name);

                    if (existing != null)
                    {
                        existing.IncludeAllClaimsForUser = true;
                        existing.Type = 0;
                        context.SaveChanges();
                    }
                    else
                    {
                        scope.IncludeAllClaimsForUser = true;
                        scope.Type = ScopeType.Identity;
                        context.Scopes.Add(scope.ToEntity());
                        context.SaveChanges();
                    }
                }

                var passwordHasher = new PasswordHasher<IdentityUser>();

                var context2 = serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

                if (!context2.Users.Any(x => x.UserName == "test"))
                {
                    var identityUser = new IdentityUser("test")
                    {
                        Id = "c5bf4a66-ab06-4cd5-8a4f-35bdd7861aa7",
                        Email = "test@test.se",
                        NormalizedEmail = "test@test.se",
                        NormalizedUserName = "test",
                        SecurityStamp = "f8cf38d6-d650-46a8-a7bd-d3ef68f4395e"
                        // Don't miss this, ugly error if prop is null
                    };

                    identityUser.PasswordHash = passwordHasher.HashPassword(identityUser, "test");

                    // Some sample claims to see that the scope assignments work
                    var claims = new List<Claim>
                                     {
                                         new Claim(JwtClaimTypes.Email, "test@test.se"),
                                         new Claim(JwtClaimTypes.Role, "SomeRole"),
                                         new Claim(JwtClaimTypes.Role, "WebAdmins"), // Testing Epi access
                                         new Claim(JwtClaimTypes.GivenName, "Johan"),
                                         new Claim(JwtClaimTypes.FamilyName, "Kronberg"),
                                         new Claim(JwtClaimTypes.Picture, "https://scontent.xx.fbcdn.net/v/t1.0-1/c51.11.138.138/s40x40/421009_10150620193316810_30933175_n.jpg"),
                                     };

                    foreach (var claim in claims)
                    {
                        identityUser.Claims.Add(new IdentityUserClaim<string>
                        {
                            UserId = identityUser.Id,
                            ClaimType = claim.Type,
                            ClaimValue = claim.Value,
                        });
                    }

                    context2.Users.Add(identityUser);
                    context2.SaveChanges();
                }
            }
        }
    }
}

See the PoC in action in this recording (animated GIF, 3.90 MB)

Things I would like to find and test out is some kind of UI for managing users and a good way to pass profile and email claims on from Facebook and Google.

As usual any feedback is highly appreciated!

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