loading...
Cover image for Kentico CMS Quick Tip: Azure Active Directory Authentication
WiredViews

Kentico CMS Quick Tip: Azure Active Directory Authentication

seangwright profile image Sean G. Wright ・10 min read

Our Requirements

Give our Content Management team a way to authenticate into Kentico CMS using their existing Azure Active Directory (AD) accounts and also keep individual account authentication.


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 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 by a Kentico employee 👏🏽. Jeroen Furst, a fellow Kentico MVP 🤓, also detailed this process awhile ago on his blog.

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." 😢.

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, also has a solution up on GitHub.

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:

<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:

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

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.

/// <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 that we will create later 😎.

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

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:

private bool 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;
    }

    URLHelper.LocalRedirect(requeststate.ReturnUrl);

    return true;
}

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

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, 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.

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 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:

<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, 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

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

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

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

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
  <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.


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, 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 🙏!


Photo by Gabriel Wasylko on Unsplash

We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!

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

Or my Kentico Xperience blog series:

Posted on by:

seangwright profile

Sean G. Wright

@seangwright

dev lead @WiredViews, founding partner @craftbrewingbiz. @Kentico Xperience MVP. love to learn / teach web dev & software engineering, collecting vinyl records, mowing my lawn, craft 🍺

WiredViews

Web Dev and Marketing Agency based in Cuyahoga Falls, OH.

Discussion

markdown guide