Monday, July 3, 2017

How to setup OpenIdConnect integration between Azure AD B2C and Episerver

Setting up OpenIdConnect integration between Azure AD B2C and EPiserver isn’t straight forward. Together with my colleague Hugo Moen, we will share with you how we solved this.

This type of Identity Server can handle up to 10 million users. We found out it can be very interesting for small solutions too, since there are now cost with less than 50 thousand users and up to 50 thousand logins per month. You would need to require an azure subscription to use this service, but you probably have one if you have hosted an Episerver solution in Azure.

What’s the different between Azure AD and Azure AD B2C?
Azure AD is a directory service with the goal to server organizations and their needs for identity management in the cloud.

Azure AD B2C is another service built on the same technology, but not on the same in functionality as Azure AD. Azure AD B2C target is to build a directory for consumer applications where users can register with e-mail ID or social providers like LinkedIn, Facebook, Google and so on. The goal for Azure AD B2C is to allow organizations to manage single directory of customer identities shared among all applications.

Some more information can be found here: http://predica.pl/blog/azure-ad-b2b-b2c-puzzled-out/

Setting up Azure AD B2C
In https://portal.azure.com you will setup Azure AD B2C. You can read more about how to do that here: https://docs.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-get-started

Adding Groups
Today in Episerver we use groups to set access right to different pages, blocks and folders. We usually use WebAdmins or Administrators. In Azure AD B2C you can manage users and groups.
But this Azure AD B2C groups isn't a part of the claims in the token you recive after you have loggedin, so we would have to fetch them through the graph api after you have been authenticated.

When you create groups it's probably smart to call the first group "WebAdmins" to avoid challenges with Episerver later on. The groups that's added to the users will be synchronized to episerver through the code later on.



The code in the example is applied to a  Episerver CMS 10 version with the Alloy templates.

NB! Nuget Package
Verify that this nuget package isn’t installed in you in episerver solution:

  • EPiServer.CMS.UI.AspNetIdentity

If it is, then remove it, since it’s doesn’t work well with other IAM integrations. You could end up getting null references in different views. You only need it if you are going to use Episerver’s own version for ASP.NET Identity 2.0. If you use it already on existing solution, I would recommed to wait to remove it until you got this new B2C solution up and running.

Install necessary Nuget Packages
Now we need to install some 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

To be able to use the Graph API you will also need these packages.

PM> Install-Package Microsoft.Azure.ActiveDirectory.GraphClient
PM> Install-Package Microsoft.IdentityModel.Clients.ActiveDirectory


Code in Startup.cs


using System;
using System.Configuration;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;

using Owin;
using Microsoft.Owin;
using Microsoft.Owin.Extensions;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Notifications;
using Microsoft.Owin.Security.OpenIdConnect;

using EPiServer.Security;
using EPiServer.ServiceLocation;

using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.Azure.ActiveDirectory.GraphClient;

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

namespace Alloydemo
{
    public class Startup
    {
        const string AAD_GRAPH_URI = "https://graph.windows.net";

        private static string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
        private static string appKey = ConfigurationManager.AppSettings["ida:AppKey"];
        private static string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
        private static string tenant = ConfigurationManager.AppSettings["ida:Tenant"];

        private string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
        
        private static readonly string PostLogoutRedirectUri = ConfigurationManager.AppSettings["ida:PostLogoutRedirectUri"];

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

        public void Configuration(IAppBuilder app)
        {
            var authContext = new AuthenticationContext(string.Format(aadInstance, tenant));
            var clientCredential = new ClientCredential(clientId, appKey);
            var graphUri = new Uri(AAD_GRAPH_URI);
            var serviceRoot = new Uri(graphUri, tenant);
            this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential));

            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = clientId,
                Authority = authority,
                PostLogoutRedirectUri = PostLogoutRedirectUri,
                TokenValidationParameters = new System.IdentityModel.Tokens.TokenValidationParameters
                {
                    ValidateIssuer = false,
                    RoleClaimType = ClaimTypes.Role
                },
                UseTokenLifetime = false, //To avoid that the session expires after 5 min 
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);
                        return Task.FromResult(0);
                    },
                    AuthorizationCodeReceived = async (context) => await AuthorizationCodeReceived(context),
                    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;
                        }
                        return Task.FromResult(0);
                    }
                }
            });
            app.UseStageMarker(PipelineStage.Authenticate);

            //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);
                });
            });
            app.Map(LogoutPath, map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    return Task.FromResult(0);
                });
            });
        }

        private Task AuthorizationCodeReceived(AuthorizationCodeReceivedNotification context)
        {
            return Task.Run(async () =>
            {
                var oidClaim = context.AuthenticationTicket.Identity.Claims.FirstOrDefault(c => c.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier");

                var identity = new ClaimsIdentity(context.AuthenticationTicket.Identity.AuthenticationType);
                if (identity.IsAuthenticated)
                {
                    identity.AddClaims(context.AuthenticationTicket.Identity.Claims);

                    if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                    {
                        var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync();

                        do
                        {
                            var directoryObjects = pagedCollection.CurrentPage.ToList();
                            foreach (var directoryObject in directoryObjects)
                            {
                                var group = directoryObject as Group;
                                if (group != null)
                                {
                                    identity.AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
                                }
                            }
                            pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null;
                        }
                        while (pagedCollection != null);
                    }

                    context.AuthenticationTicket = new AuthenticationTicket(
                        new ClaimsIdentity(identity.Claims, context.AuthenticationTicket.Identity.AuthenticationType), context.AuthenticationTicket.Properties);

                    //Sync user and the roles to EPiServer in the background
                    await ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(context.AuthenticationTicket.Identity);
                }

            });
        }

        private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential)
        {
            AuthenticationResult result = null;
            var retryCount = 0;
            var retry = false;

            do
            {
                retry = false;
                try
                {
                    // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
                    result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
                }
                catch (AdalException ex)
                {
                    if (ex.ErrorCode == "temporarily_unavailable")
                    {
                        retry = true;
                        retryCount++;
                        await Task.Delay(3000);
                    }
                }
            } while (retry && (retryCount < 3));

            if (result != null)
            {
                return result.AccessToken;
            }

            return null;
        }

        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();
            }
        }

        public ActiveDirectoryClient aadClient { get; set; }

    }
}


Web.config
Add the following code snippets into the web.config.

  <appSettings>
    <add key="ida:ClientId" value="xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx" />
    <add key="ida:AppKey" value="<- app key ->" />
    <add key="ida:Tenant" value="<-YourTenant->.onmicrosoft.com" />
    <add key="ida:AADInstance" value="https://login.microsoftonline.com/{0}" />
    <add key="ida:RedirectUri" value="http://localhost:7676/" />
    <add key="ida:PostLogoutRedirectUri" value="http://localhost:7676/"/>
  </appSettings>
..
 <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>


Access Rights to the Graph Api 

NB! You need to set the correct access right to the Graph Api or else you will end up with a error like this after you have logged in:


  • Insufficient privileges to complete the operation


So to fix this you have to install the Windows Azure AD Module for Windows PowerShell and apply the correct role to the application.

  1. Use a domain administrator account, when starting up Powershell.
    • Directory role => Global administrator
  2. Referring to the Microsoft documentation:
    • Ensure that your server meets the following Windows Azure AD Module for Windows PowerShell requirements:
      • Windows 7, Windows 8, Windows Server 2008 R2, or Windows Server 2012.
      • Microsoft .NET Framework 3.51 feature.
    • Download and install the appropriate Microsoft Online Services Sign-In Assistant version for your operating system (see Microsoft Online Services Sign-In Assistant for IT Professionals RTW).
  3.  Install the Windows Azure Active Directory Module for Windows PowerShell (see Install the Windows Azure AD Module ).
  4. Connect to Windows Azure AD by running the PowerShell command:
    •  import-module MSOnline 
  5. Logg on Azure:
    • Connect-MsolService => Login screen pop up
  6. List out application with ID's to find the correct application ID
    • Get-MsolServicePrincipal | ft DisplayName, AppPrincipalId -AutoSize
  7. Set correct value and ID:
    • $clientIdApp = 'XXXXXX-XXXX-XXXX-XXXX-XXXXXXXX'
    • $webApp = Get-MsolServicePrincipal –AppPrincipalId $clientIdApp
  8. Add a read role ("Directory Readers") to the application:
    • Add-MsolRoleMember -RoleName "Directory Readers" -RoleMemberType ServicePrincipal -RoleMemberObjectId $webApp.ObjectId 
So, if you have done everything correct, you should now be able to login to the Episerver through Azure AD B2C. The nice part is that if you use an AD account that is connected to other services, like office 365, you will have a perfect SSO solution.

2 comments:

  1. Thanks for sharing such a value able experience.

    ReplyDelete
  2. Do you honestly understand everything in the Startup class?

    ReplyDelete