Kentico CMS Quick Tip: Azure Active Directory Authentication
Tags: xperience, kentico, azure, dotnet
Published: 2020-07-20 18:00:10

---
title: Kentico CMS Quick Tip: Azure Active Directory Authentication
published: true
description: Learn how to add optional Azure Active Directory authentication to the Content Management application
tags: xperience, kentico, azure, dotnet
series: Kentico Quick Tips
cover_image: https://dev-to-uploads.s3.amazonaws.com/i/5cn1k9t0efs03i03hjn3.jpg
---

## Our Requirements

Give our Content Management team a way to authenticate into [Kentico CMS](https://xperience.io/) using their existing Azure Active Directory (AD) accounts and also _keep_ individual account authentication.

> Note: This article applies to Kentico 12. If you are using Kentico Xperience 13, please see the note at the end of the article to see how configuration is different in v13.

---

## How Does the Built In Authentication Work?

Authenticating into the Kentico CMS content management application is a pretty simple process 😉:

- Navigate to `/Admin`
- Unauthenticated users are redirected to `/CMSPages/logon.aspx`
- After entering a valid username and password, the application responds with an Authentication cookie.
- Requests to the application check for this cookie when accessing routes requiring authentication.

This process relies on ASP.NET's [Forms Authentication](https://docs.microsoft.com/en-us/aspnet/web-forms/overview/older-versions-security/introduction/an-overview-of-forms-authentication-cs) and all credentials are stored in the application's database.

---

## Existing Solutions

If we want to integrate Azure AD into Kentico CMS for authentication, maybe we can use an existing open source solution or one blogged about by the Kentico CMS community 🧐!

### Built-In Claims Based Authentication

First off, there is a solution that Kentico Xperience supports out of the box and it's detailed [on Kentico's DevNet](https://devnet.kentico.com/articles/integrate-azure-active-directory-with-kentico) by a Kentico employee 👏🏽. Jeroen Furst, a fellow Kentico MVP 🤓, also detailed [this process awhile ago on his blog](https://blogs.jeroenfurst.nl/blog/november-2018/claims-based-authentication-using-azure-ad).

However, this solution completely replaces Kentico Xperience's Forms Authentication with Azure AD authentication because ["Mixed authentication mode of claims-based and forms authentication is also not supported."](https://docs.kentico.com/k12sp/managing-users/user-registration-and-authentication/claims-based-authentication) 😢.

This means all users of the application will need to have Azure AD accounts in the tenant specified by the App Registration in Azure AD.

As an agency, we want to use Azure AD for our authentication into our client's sites, but we also want to allow them to continue using Forms Authentication for their access. We definitely don't want to have all our clients accounts managed in our Azure AD tenant 😅!

### Portal Engine Public Site Authentication

Kentico partner, [Delete Agency](https://www.deleteagency.com/), also has [a solution up on GitHub](https://github.com/kate-orlova/azure-ad-auth-in-kentico).

This approach is much closer to what we want, as it augments rather than replaces the normal Forms Authentication 💪🏾.

However, the use-case is slightly different. This code provides authentication for Portal Engine based Kentico applications and the authentication is for live site users, not content management users 😣.

We want to follow the authentication steps that were detailed at the beginning of this post, but allow our employees to authenticate with Azure AD instead of application credentials.

## Our Solution

Despite the fact that the Delete approach is for live site authentication, it follows the general outline of our approach 👍🏼.

### Customizing the Logon Form

First, let's update the normal back end login form found in `CMSApp/CMSPages/logon.aspx`.

Replace the last `<div>` inside the `<asp:Panel>` (starting on line  100) with the following:

```html
<div class="row" 
     style="display: flex; justify-content: space-between">

    <span>
        <cms:LocalizedButton ID="btnLogonAzureAD" 
            ButtonStyle="Primary" 
            CssClass="login-btn"
            Text="Logon With Azure AD" runat="server"
            OnClick="btnLogonAzureAD_Click" />
    </span>

    <span>
        <cms:LocalizedButton ID="LoginButton" 
            runat="server" 
            CommandName="Login" ValidationGroup="Login1"
            ButtonStyle="Primary" CssClass="login-btn" />
    </span>
</div>
```

All we did here was add a new button with the text "Logon With Azure AD" and some styles and markup to layout the buttons correctly.

Now let's add the C# in `logon.aspx.cs` to wire the button up:

```csharp
protected void btnLogonAzureAD_Click(object sender, EventArgs e)
{
    // We will uncomment this line once we've created the
    // handler class

    // AzureADAuthenticationHandler.OnLogin(ReturnUrl);
}
```

Pretty easy 😅!

If we start up the content management application and navigate to `/Admin` (assuming we are not logged in), we will see this:

![Kentico logon form with additional Azure AD logon button](https://dev-to-uploads.s3.amazonaws.com/i/fy5ucpr02uln6xld2zjd.jpg)

### Creating a Custom `IHttpHandler`

Now we can create the handler class referenced in our `logon.aspx.cs` above:

> If you are adding this directly the CMS code, you can create the class in `Old_App_Code`.

```csharp
/// <summary>
/// Handles both the initial OAuth request to Azure AD and the redirect response
/// to authenticate the user into Kentico.
/// 
/// Operates according to the flow described here https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
/// </summary>
public class AzureADAuthenticationHandler : IHttpHandler
{
    private static readonly string clientId;
    private static readonly string redirectUriPath;
    private static readonly string tenantId;
    private static readonly string clientSecret;
    private static readonly string groupClaimId;
    private static readonly string stateCookieName = "azure_ad_sso_state";

    public bool IsReusable => false;

    static AzureADAuthenticationHandler()
    {
        var settings = ConfigurationManager.AppSettings;

        clientId = settings["authentication:azure-ad:client-id"];
        redirectUriPath = settings["authentication:azure-ad:redirect-uri-path"];
        tenantId = settings["authentication:azure-ad:tenant-id"];
        clientSecret = settings["authentication:azure-ad:client-secret"];
        groupClaimId = settings["authentication:azure-ad:group-claim-id"];
    }

    // ...

}
```

First we read in a bunch of values from App Settings when the application starts up. These will all be populated from configuration in the [Azure AD App Registration](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) that we will create later 😎.

Now we will create the `Logon` method that will be called by `logon.aspx.cs`:

```csharp
public static void OnLogin(string returnUrl)
{
    var uri = new UriBuilder($"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize");

    /*
    * This state is both stored in a cookie and sent to the Azure AD authenticate URL
    * via query string.
    * 
    * When the redirect comes back after the Azure AD sign-in, we want to ensure
    * the request isn't an XSRF or replay attach so we pull the state values
    * out of the cookie and ensure they match what's in the query string.
    * 
    * The "ReturnUrl" captured by the Kentico login page allows us to send the user back
    * to their original destination if they were redirected to login for authentication.
    * 
    * After logging in we clear the state cookie.
    */
    var state = new OIDCState { ReturnUrl = returnUrl, Id = Guid.NewGuid().ToString() };

    string encodedState = Base64Encode(JsonConvert.SerializeObject(state));

    var parameters = HttpUtility.ParseQueryString(string.Empty);
    parameters.Add(new NameValueCollection
    {
        ["client_id"] = clientId,
        ["response_type"] = "code",
        ["response_mode"] = "query",
        // We request the 'profile' scope to get access to the user's Azure AD groups
        // to ensure they are allowed to SSO into Kentico
        // https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-optional-claims#configuring-groups-optional-claims
        ["scope"] = "openid profile",
        ["redirect_uri"] = $"{HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority)}{redirectUriPath}",
        ["state"] = encodedState
    });

    uri.Query = parameters.ToString();

    /*
    * We can't use CookieHelper.SetValue() because it requires an expiration
    * date and Chrome won't process the set-cookie header on 302 responses
    * when an Expires cookie value is set
    * https://bugs.chromium.org/p/chromium/issues/detail?id=696204
    * 
    * This problem does not appear in localhost requests or in other browsers
    */
    HttpContext.Current.Response.AppendCookie(new HttpCookie(stateCookieName)
    {
        HttpOnly = true,
        Value = encodedState,
        SameSite = SameSiteMode.Lax,
        Secure = true,
        Path = "/",
        Domain = HttpContext.Current.Request.Url.Host
    });

    HttpContext.Current.Response.Redirect(uri.ToString());
}
```

This code creates a security cookie / state value to protect against XSRF attacks 😮 and also generates a redirect request to Azure AD to ensure the user is authenticated there.

We now create an `Authenticate()` method that handles the request back to our application, redirected from Azure AD after the user is authenticated there:

```csharp
private (bool isAuthenticated, string redirectUrl) Authenticate(HttpContext context)
{
    if (AuthenticationHelper.IsAuthenticated())
    {
        return (false, "");
    }

    var query = context.Request.QueryString;

    string code = query.Get("code");
    string encodedRequestState = query.Get("state");
    string encodedCookieState = CookieHelper.GetValue(stateCookieName);

    if (string.IsNullOrWhiteSpace(code) ||
        string.IsNullOrWhiteSpace(encodedRequestState) ||
        string.IsNullOrWhiteSpace(encodedCookieState))
    {
        return (false, "");
    }

    var requeststate = JsonConvert.DeserializeObject<OIDCState>(Base64Decode(encodedRequestState));
    var cookiestate = JsonConvert.DeserializeObject<OIDCState>(Base64Decode(encodedCookieState));

    if (!string.Equals(requeststate.Id, cookiestate.Id, StringComparison.OrdinalIgnoreCase))
    {
        return (false, "");
    }

    string uri = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";

    var parameters = new NameValueCollection
    {
        ["client_id"] = clientId,
        ["scope"] = "openid profile",
        ["code"] = code,
        ["redirect_uri"] = $"{HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Authority)}{redirectUriPath}",
        ["grant_type"] = "authorization_code",
        ["client_secret"] = clientSecret
    };

    string responseString = "";

    using (var client = new WebClient())
    {
        try
        {
            byte[] response = client.UploadValues(uri, "POST", parameters);

            responseString = Encoding.Default.GetString(response);
        }
        catch (Exception ex)
        {
            EventLogProvider.LogException(nameof(AzureADAuthenticationHandler), "AUTH_FAILURE", ex);

            return (false, "");
        }
    }

    if (string.IsNullOrWhiteSpace(responseString))
    {
        return (false, "");
    }

    var resp = JsonConvert.DeserializeObject<OIDCResponse>(responseString);

    if (string.IsNullOrWhiteSpace(resp.AccessToken))
    {
        return (false, "");
    }

    var handler = new JwtSecurityTokenHandler();

    /*
    * Here are the claims available in each token type
    * https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens
    * https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
    * 
    * The difference between the token types is important when using them to access
    * APIs on behalf of the user, but we only use them for their payload data.
    */
    var accessToken = handler.ReadJwtToken(resp.AccessToken);
    var idToken = handler.ReadJwtToken(resp.IdToken);

    if (!idToken.Claims.Any(c => c.Type == "groups" && string.Equals(c.Value, groupClaimId, StringComparison.OrdinalIgnoreCase)))
    {
        return (false, "");
    }

    string upn = GetClaimValue(accessToken, "upn", null);
    string email = GetClaimValue(accessToken, "email", null);

    /*
    * Guest accounts (invited from another Azure AD tenant/domain) don't expose the 'upn' claim
    * so we have to get the username/email from the 'email' claim
    */
    string identifier = upn ?? email ?? "";

    if (string.IsNullOrWhiteSpace(identifier))
    {
        return (false, "");
    }

    var user = UserInfoProvider.GetUserInfo(identifier);

    if (user is null)
    {
        user = new UserInfo
        {
            IsExternal = true,
            UserName = identifier,
            Email = identifier,
            FirstName = GetClaimValue(accessToken, "given_name"),
            LastName = GetClaimValue(accessToken, "family_name"),
            Enabled = true,
            FullName = GetClaimValue(accessToken, "name"),
            SiteIndependentPrivilegeLevel = UserPrivilegeLevelEnum.GlobalAdmin
        };

        UserInfoProvider.SetUserInfo(user);
        UserInfoProvider.AddUserToSite(user.UserName, SiteContext.CurrentSiteName);
        UserInfoProvider.AddUserToRole(user.UserID, 3);
    }

    AuthenticationHelper.AuthenticateUser(identifier, true);
    CookieHelper.Remove(stateCookieName);

    if (string.IsNullOrWhiteSpace(requeststate.ReturnUrl))
    {
        return (true, "/Admin");
    }

    return (true, requeststate.ReturnUrl);
}
```

To get all the compilation errors to go away we need a couple utility methods and classes:

```csharp
private static string Base64Encode(string value)
{
    byte[] valueBytes = Encoding.UTF8.GetBytes(value);
    return Convert.ToBase64String(valueBytes);
}

private static string Base64Decode(string value)
{
    byte[] valueBytes = Convert.FromBase64String(value);
    return Encoding.UTF8.GetString(valueBytes);
}

private static string GetClaimValue(JwtSecurityToken token, string claimType, string defaultValue = "") =>
    token.Claims
      .FirstOrDefault(c => 
          c.Type.Equals(claimType, StringComparison.OrdinalIgnoreCase))
      ?.Value ?? defaultValue;

// ...

internal class OIDCState
{
    public string ReturnUrl { get; set; }
    public string Id { get; set; }
}

internal class OIDCResponse
{
    [JsonProperty("access_token")]
    public string AccessToken { get; set; }
    [JsonProperty("token_type")]
    public string TokenType { get; set; }
    [JsonProperty("expires")]
    public long Expires { get; set; }
    [JsonProperty("scope")]
    public string Scope { get; set; }
    [JsonProperty("refresh_token")]
    public string RefreshToken { get; set; }
    [JsonProperty("id_token")]
    public string IdToken { get; set; }
}
```

We also need to install a NuGet package, [System.IdentityModel.Tokens.Jwt](https://www.nuget.org/packages/System.IdentityModel.Tokens.Jwt/), to help us parse the Jwt that comes back from Azure AD when we request it to verify the token sent to us in the URL query string.

---

Finally, we need to implement `IHttpHandler` and add a `ProcessRequest()` method:

```csharp
public void ProcessRequest(HttpContext context)
{
    var (isAuthenticated, returnUrl) = Authenticate(context);

    if (!isAuthenticated)
    {
        EventLogProvider.LogEvent("I", "Azure AD Authentication", "AUTHENTICATIONFAIL", $"Could not log in user");
    }
    else
    {
        EventLogProvider.LogEvent("I", "Azure AD Authentication", "AUTHENTICATIONSUCCESS", $"Authentication success");
    }

    URLHelper.LocalRedirect(returnUrl);
}
```

We can now go back to `logon.aspx.cs` and comment the line `AzureADAuthenticationHandler.OnLogin(ReturnUrl);` 👍🏻.

### Adding the Handler to the `web.config`

We want our handler to handle any requests matching a specific URL pattern, which in following [OIDC authentication](https://auth0.com/docs/protocols/oidc) conventions will be `/oidc-callback`.

Since our handler class inherits from `IHttpHandler`, ASP.NET will pass it requests matching a pattern by registering it in the application `web.config` correctly:

```xml
<system.webServer>
  <!-- ... -->

  <handlers>
    <!-- ... -->

    <add name="AzureADOIDCCallback" 
         path="oidc-callback" verb="GET" 
        type="Namespace.AzureADAuthenticationHandler, CMSApp"
        preCondition="integratedMode,runtimeVersionv4.0" />
  </handlers>
```

In the above code, `Namespace.AzureADAuthenticationHandler` should be the namespace of your `AzureADAuthenticationHandler` class and `CMSApp` should be the name of the assembly your class is located in.

### Creating an Application Registration in Azure

Now we can create a new App Registration in Azure AD.

Navigate to [https://portal.azure.com](https://portal.azure.com), open the Azure AD blade (or search for Azure AD in the search bar), select "App Registrations" from the left side menu, and click "+ New Registration" at the top-left of the screen.

Now, fill in the details, giving it a friendly name and specifying the full Redirect URI with the path matching what we defined in the `web.config` for the handler (eg: `https://localhost:44332/oidc-callback`):

![Setting application Redirect URI in Azure](https://dev-to-uploads.s3.amazonaws.com/i/4yj0mc9ox40cn4vhjjn9.jpg)

Next, collect the identifiers from the "Overview" blade accessed from the left side menu. We want the "Application (client) ID" and "Directory (tenant) ID":

![App registration properties](https://dev-to-uploads.s3.amazonaws.com/i/q92c34ghql6kkppzo0lv.jpg)

This implementation restricts authentication to users that are in a specific Azure AD group, but to get access to a user's groups, we need to add an "Optional claim" by selecting the "Token configuration" option from the left menu 🧐.

Select "+ Add groups claim" and check "Security groups" and save:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/ge7adz9thahhjihyvyu5.jpg)

The last step for the App Registration is to create a client secret that the Kentico application can use to verify the OIDC callback token and get the ID and Access tokens from the Azure AD API.

Select "Certificates & secrets" from the left side menu and click "+ New client secret":

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/u42miet848h4adikgnew.jpg)

We have to make sure we copy the client secret and save it as we won't be able to see it again later 😏.

Finally, create an Azure AD Group and assign some users to it - only users in this group will be allowed to use the Azure AD SSO functionality in Kentico. Copy its "Object Id" from the Groups list and save this for our App Settings.

### Adding Our Application Settings

Now we only need to configure the application settings by copying the values from eariler.

- `tenant-id` from the App Registration Overview
- `client-id` from the App Registration Overview
- `redirect-uri-path` matches the `web.config` `path` for the handler
- `client-secret` from the client secret we created in the "Certificats & secrets" blade
- `group-claim-id` from the "Object Id" in the Azure AD Groups blade

```xml
  <add key="authentication:azure-ad:tenant-id" value=""/>
  <add key="authentication:azure-ad:client-id" value=""/>
  <add key="authentication:azure-ad:redirect-uri-path" value="/oidc-callback"/>
  <add key="authentication:azure-ad:client-secret" value=""/>
  <add key="authentication:azure-ad:group-claim-id" value=""/>
```

The only other setting we need is to tell Kentico to ignore the `/oidc-callback` path and not try to match it up with a path handled by the content management application URL processing used in Portal Engine sites 😉.

Log into the content management app, and navigate to the Settings module. Click "URLs and SEO", scroll to "URL format", find the Excluded URLs setting and add `/oidc-callback`.

### Kentico Xperience 13 configuration

If you are using Kentico Xperience 13, you'll notice that the "Excluded URLs" field in the Settings module no longer exists. There is a "Excluded Alternative URLs" field, but this configures the live site ASP.NET Core application, not the CMS administration application.

To hook into the request pipeline and tell Xperience to ignore handling requests starting with `/oidc-callback`, you can add this custom module to your `CMSApp` project.

```csharp
   [assembly: RegisterModule(typeof(AzureADAuthenticationRoutingModule))]

   namespace AppCodeName.Management.Web.Authentication
   {
       public class AzureADAuthenticationRoutingModule 
           : Module
       {
           public AzureADAuthenticationRoutingModule() 
               : base(nameof(AzureADAuthenticationRoutingModule))
           {
               var appSettings = ConfigurationManager.AppSettings;

               string redirectUriPath = appSettings["authentication:azure-ad:redirect-uri-path"];

               URLRewritingEvents.ProcessRewritingResult.Before += (s, e) =>
               {
                   if (e.Parameters.RelativePath.StartsWith(redirectUriPath, StringComparison.OrdinalIgnoreCase))
                   {
                       e.Cancel();
                   }
               };
           }
       }
   }
```

This code tells the administration application to _not_ try to handle (rewrite) requests starting with whatever value is stored in our `authentication:azure-ad:redirect-uri-path` `web.config` key. In this post's example, that would be `/oidc-callback`.

---

## Conclusion

Our Azure AD SSO integration is complete. We can now go to the login page of the content management app and click the "Logon With Azure AD", go through the OAuth redirection flow, and be logged straight into the application without having to supply any credentials 🎉🎉🥳.

In addition to the above workflow, new users in our Azure AD tenant that don't already have accounts in Kentico, will have accounts automatically created for them during sign-in.

The above code sets all SSO users as Global Administrators 🤠 when new accounts are being created, but we could instead add Azure AD Groups as Kentico Roles and then ensure the Roles of the authenticating user inside Kentico matched the Azure AD Groups.

If you run into any issues setting this up, leave a comment below and we'll figure it out. There's a lot of separate steps here and some tricky configuration.

This Azure AD authentication integration has worked great for us at  [WiredViews](https://www.wiredviews.com), reducing the complexity and tedium of user management across all our Kentico applications. I hope others find it useful as well.

As always, thanks for reading 🙏!

---

<figcaption>Photo by <a href="https://unsplash.com/@gabewasylko">Gabriel Wasylko
</a> on <a href="https://unsplash.com">Unsplash</a></figcaption>

---

We've put together a list over on [Kentico's GitHub account](https://github.com/Kentico/Home/blob/master/RESOURCES.md) of developer resources. Go check it out!

If you are looking for additional Kentico content, checkout the Kentico tags here on DEV:

{% tag kentico %}

{% tag kontent %}

Or my Kentico Xperience blog series:

- [Kentico EMS: MVC Widget Experiements](https://dev.to/search?q=kentico%20ems%3A%20mvc%20widget%20experiements)
- [Kentico 12: Design Patterns](https://dev.to/search?q=Kentico%2012%3A%20Design%20Patterns)
- [Kentico Quick Tips](https://dev.to/search?q=Kentico%20CMS%20Quick%20Tip)