Monday, April 25, 2016

How to setup OAuth2.0 integration between IdentityServer3 and Episerver

There exists a couple blog post on how to integrate Episerver with WS-Federation protocol, which also is support in IdentityServer3. But this blog post will introduce to you, how to setup OAuth2.0 integration between Episerver and IdentityServer3. IdentityServer3 is a popular open source security token service framework written in .NET, that implements the OpenID Connect and OAuth2 protocols. In this example we use the “Hybrid”-flow, which also contain the refresh token that’s used to obtain and renewed the identity token. Which is a really nice feature in modern SSO strategy.

For you who want to read an introduction to this, can click here: http://sveinaandahl.blogspot.no/2016/04/how-to-do-identity-and-access.html



Install necessary Nuget Packages
First of all you need to install som nuget packages. Open the Tools > Nuget Package Manager > Package Manager Console and run this commands:

PM> Install-Package Microsoft.Owin.Security.Cookies
PM> Install-Package Microsoft.Owin.Host.SystemWeb
PM> Install-Package Microsoft.Owin.Security.OpenIdConnect


NOTE
Since the implementaion of the OpenID Connect and OAuth2 protocols usually use JSON web token(JWT), the token format look something like this:
//claims
{
 "sub": "svein@knowit.no",
 "name": "Svein Aandahl",
 "role": "Administator"
}

While the URI in a assertion (claims) in a Saml1.1 token looks like this
Role and Name claim
http://schemas.microsoft.com/ws/2008/06/identity/claims/role
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name

Why is this importen to be aware of? The Episerver implementation only support the Saml 1.1 token standard. So when you use the OAuth 2.0 you would need to adjust some of the code to make this work in Episerver.

Code in Startup.cs
Create a startup.cs file on root and add following code.

using EPiServer.Security;
using EPiServer.ServiceLocation;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Owin;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Helpers;
using IdentityModel.Client;
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin.Security.OpenIdConnect;

[assembly: OwinStartup(typeof(EPiServer.Templates.Alloy.Startup))]

namespace EPiServer.Templates.Alloy
{
    public class Startup
    {
        const string LogoutUrl = "/util/logout.aspx";
        const string LoginUrl = "/login";
        const string clientId = "Client-Name";
        const string IdSrvUrl = "https://idsrv.local";
        const string ClientUrl = "http://website.local/";

        public void Configuration(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

            AntiForgeryConfig.UniqueClaimTypeIdentifier = "sub";
            JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();
            JwtSecurityTokenHandler.OutboundClaimTypeMap = new Dictionary<string, string>();

            // Sets the authentication type for the owin middleware
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = "Cookies"
            });

            // Uses OpenIdConnect middleware for authentication
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = clientId,

                Authority = IdSrvUrl + "/core/", 
                RedirectUri = ClientUrl, 
                PostLogoutRedirectUri = ClientUrl, 
                ResponseType = "code id_token token",
                Scope = "openid email profile offline_access roles",
                UseTokenLifetime = false, //To avoid that the session expires after 5 min 
                SignInAsAuthenticationType = "Cookies",

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthorizationCodeReceived = async n =>
                    {
                        // use the code to get the access and refresh token
                        var tokenClient = new TokenClient(
                            IdSrvUrl + "/core/connect/token",
                            clientId, 
                            "secret");

                        var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(n.Code, n.RedirectUri);

                        // use the access token to retrieve claims from userinfo
                        var userInfoClient = new UserInfoClient(
                            new Uri(IdSrvUrl + "/core/connect/userinfo"),
                            tokenResponse.AccessToken);

                        var userInfoResponse = await userInfoClient.GetAsync();

                        // create new identity
                        var id = new ClaimsIdentity(n.AuthenticationTicket.Identity.AuthenticationType);
                        if (id.IsAuthenticated)
                        { 
                            id.AddClaims(userInfoResponse.GetClaimsIdentity().Claims);
                        
                            // Add name claim as http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
                            var name = id.Claims.Where(x => x.Type == "name").Select(x => x.Value).FirstOrDefault() ?? 
                                       id.Claims.Where(x => x.Type == "preferred_username").Select(x => x.Value).FirstOrDefault();
                            id.AddClaim(new Claim(ClaimTypes.Name, name));

                            // Add all role claims for the user as http://schemas.microsoft.com/ws/2008/06/identity/claims/role
                            IEnumerable<Claim> roles = id.Claims.Where(x => x.Type == "role");
                            foreach (var role in roles)
                            {
                                id.AddClaim(new Claim(ClaimTypes.Role, role.Value));
                            }

                            id.AddClaim(new Claim("access_token", tokenResponse.AccessToken));
                            id.AddClaim(new Claim("expires_at", DateTime.Now.AddSeconds(tokenResponse.ExpiresIn).ToLocalTime().ToString()));
                            id.AddClaim(new Claim("refresh_token", tokenResponse.RefreshToken));
                            id.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
                        }

                        n.AuthenticationTicket = new AuthenticationTicket(
                            new ClaimsIdentity(id.Claims, n.AuthenticationTicket.Identity.AuthenticationType),
                            n.AuthenticationTicket.Properties);

                    },

                    RedirectToIdentityProvider = n =>
                    {
                        // if signing out, add the id_token_hint
                        if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.LogoutRequest)
                        {
                            var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");
                            if (idTokenHint != null)
                            {
                                n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
                            }
                        }
                        return Task.FromResult(0);
                    },

                    SecurityTokenValidated = (ctx) =>
                    {
                        //Ignore scheme/host name in redirect Uri to make sure a redirect to HTTPS does not redirect back to HTTP
                        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<OAuth2SynchronizingUserService>().SynchronizeAsync(ctx.AuthenticationTicket.Identity);

                        return Task.FromResult(0);
                    },
                }
            });
            //Remap login
            app.Map(LoginUrl, map =>
            {
                map.Run(ctx =>
                {
                    if (ctx.Authentication.User == null ||
                        !ctx.Authentication.User.Identity.IsAuthenticated)
                    {
                        ctx.Response.StatusCode = 401;
                    }
                    else
                    {
                        ctx.Response.Redirect("/");
                    }
                    return Task.FromResult(0);
                });
            });
            //Remap logout 
            app.Map(LogoutUrl, map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    return Task.FromResult(0);
                });
            });

            //Tell antiforgery to use the name claim
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.Name;
        }
    }
}

To be able to sync roles up from Identity Server when the user login, we need to replace the "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" with "role".

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using EPiServer.Async;
using EPiServer.Framework;
using EPiServer.Security;
using EPiServer.ServiceLocation;

namespace TestSite.Business.ServiceLocation
{
    [ServiceConfiguration(typeof(SynchronizingUserService))]
    public class OAuth2SynchronizingUserService : SynchronizingUserService
    {
        public override Task SynchronizeAsync(ClaimsIdentity identity)
        {
            Validator.ThrowIfNull("identity", identity);
            if (!identity.IsAuthenticated)
                throw new ArgumentException("The identity is not authenticated", nameof(identity));

            var name = identity.Name ?? identity.Claims.Single(c => c.Type == "aud").Value;
            var roles = GetRolesFromClaims(identity);
            return ServiceLocator.Current.GetInstance<TaskExecutor>().Start(() => SynchronizeUserAndRoles(name, roles));
        }

        private static List<string> GetRolesFromClaims(ClaimsIdentity identity)
        {
            return identity.Claims.Where(c => c.Type == "role").Where(c => !string.IsNullOrEmpty(c.Value)).Select(c => 
c.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
        }
    }
}

Web.config
You need to do some modification in web.config and remove membership provider .


    <authentication mode="None"/>

    <membership>
      <providers>
        <clear/>
      </providers>
    </membership>
    <roleManager enabled="false">
      <providers>
        <clear/>
      </providers>
    </roleManager>

<episerver.framework>
    <securityEntity>
      <providers>
        <add name="SynchronizingProvider" type ="EPiServer.Security.SynchronizingRolesSecurityEntityProvider, EPiServer"/>
      </providers>
    </securityEntity>
    <virtualRoles addClaims="true">
       //existing virtual roles
    </virtualRoles>


Administration of users
After you have got everything up and running with both episerver and IdentityServer then you need to go into the admin to create a user and add roles. These roles will be populated in Episerver, so you can add them as groups in the Episerver tree.



To be able to login to Episerver editor you need to add the name and role claim as minimum.




Happy coding!

3 comments:

  1. Hi Svein, such a great post!

    Everything works as expected except this small issue, also documented with EpiServer federated security but no luck.

    "401.2 Access Denied when accessing edit mode instead of redirect to identity provider"

    I can't figure out why I get 401 response for "/episerver" admin access when I'm not authenicated, instead of redirecting to login page of identity server.

    Do you have some solution for this on your mind?

    Thanks

    ReplyDelete
    Replies
    1. Hi Bojan,

      It's a really easy fix. Just add one line to the CookieAuthenticationOptions:

      app.UseCookieAuthentication(new CookieAuthenticationOptions
      {
      AuthenticationType = "Cookies",
      LoginPath = new PathString(LoginUrl)
      });

      Hope you didn't scratch your head for too long about this! :)

      PS. Weird if EpiServers own team didn't manage to find the issue. If you look at their standard Startup.cs it looks like this:

      app.UseCookieAuthentication(new CookieAuthenticationOptions
      {
      AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
      LoginPath = new PathString(Global.LoginPath),
      Provider = new CookieAuthenticationProvider
      {
      ...
      }
      });

      Delete
  2. Hi I've implemented the same with my application's client id, client secret and end points which we got after registering our application in Cloud access manager. But don't know what's the issue but its not firing Authorizationcoderecieved section.
    Please help me out. I can even post my code if you want to have a look at it.

    Thanks,
    Shanthi

    ReplyDelete

Note: Only a member of this blog may post a comment.