Making outbound requests from an ASP.NET MVC/WebAPI application using Kerberos delegated credentials

20 Jun 2016

Part 1: Lessons learned setting up an IIS web application for double-hop Kerberos authentication with delegation
Part 2: Diagnosing network issues by building TCP/UDP ping into an application
Part 3: Making outbound requests from an ASP.NET MVC/WebAPI application using Kerberos delegated credentials

In an earlier post on troubleshooting Kerberos authentication with delegation, we relied on DelegConfig to verify the end to end authentication flow. In reality, a custom ASP.NET MVC/WebAPI application would replace DelegConfig in making calls to the Kerberos enabled end-system. For an application to use Kerberos authentication, obviously the infrastructure must be properly configured. But that doesn't mean the application will automatically use Kerberos with delegation.

Problem

This post develops the code needed for a web application to call into an end-system using its calling user, i.e., ensure the inbound and outbound credentials remain the same. For privilege escalation, where inbound and outbound credentials need to differ, we develop a way to escape Kerberos delegation and execute code with custom credentials.

To show the switching of outbound credentials in action, we call a who-am-I API exposed by the end-system. We make SharePoint return to the service the identity of the calling user. The service then writes out the identity to the browser. The code responsible for switching credentials, however, isn't restricted to SharePoint, but works with any Kerberos enabled end-system.

Without Kerberos authentication with delegation, we would most likely have to fall-back on the trusted subsystem model. The service would take care of authenticating visitors, but then use a service account to communicate with the end-system. With SharePoint, if the service were to perform a search, no user specific security trimming would be applied to the result. Depending on the data we store in SharePoint, this may or may not be a problem.

Initial solution

Let's see if we can experiment our way toward a solution. As a first attempt, we wrap the instantiation of ClientContexts in a factory (although it later turns out not to be a good solution). The factory collects, in a single place, the details of creating contexts with different authentication settings. The ClientContext itself acts like a proxy for operations performed against SharePoint. Other end-systems would likely expose similar APIs, or one would use WebClient and HttpWebRequest, directly:

// To make the code compile, add a NuGet referece to Microsoft.SharePointOnline.CSOM.
public class ClientContextFactory {
    public WindowsImpersonationContext ImpersonationContext { get; private set; }

    // Doesn't always return a context using app pool credentials as per
    // the reverse engineered code below. It's effectively a special case
    // of passing the app pool account's username, password, and domain
    // to WithSpecificUser.
    public ClientContext WithDefaultUser(string url) => new ClientContext(url);

    // Will always create a context with credentials from the passed
    // username, password, and domain.
    public ClientContext WithSpecificUser(string url, string username, string password, string domain) {
        return new ClientContext(url) {
            Credentials = new NetworkCredential(username, password, domain)
        };
    }

    // Impersonation relies on the Impersonate method to carry out a side
    // effect, which is to change the application/thead local DefaultCredentials.
    public ClientContext WithKerberosDelegation(string url) {
        var httpContext = System.Web.HttpContext.Current;
        var identity = httpContext.User.Identity as WindowsIdentity;
        ImpersonationContext = WindowsIdentity.Impersonate(identity.Token);

        // ClientContext constructor call appears identical to the one in
        // WithApplicationPool. But because the previous impersonation code
        // carries out a side effect on the application/thread local
        // credentials used by ClientContext, the outcome is different.
        return new ClientContext(url);
    }
}

Next, making who-am-I requests using the factory created ClientContexts, we can observe the effect of each authentication setting on the outbound credentials. For reasons which will become clear in a moment, we introduce an AcmeBaseController base controller to host shared logic:

public class AcmeBaseController : Controller {
    protected string WhoAmI(ClientContext ctx) {
        var user = ctx.Web.CurrentUser;
        ctx.Load(user);
        ctx.ExecuteQuery();
        return user.LoginName;
    }
}
    
public class HomeController : AcmeBaseController {
    public ActionResult AuthenticationTest(string targetUrl) {
        var factory = new ClientContextFactory();
        var content = "";

        // WithDefaultUser: i:0#.w|acmecorp\appPoolUser
        using (var ctx = factory.WithDefaultUser(targetUrl)) {
            content += $"WithDefaultUser: {WhoAmI(ctx)}<br/>";
        }

        // WithSpecificUser: i:0#.w|acmecorp\serviceUser
        using (var ctx = factory.WithSpecificUser(targetUrl, "serviceUser", "servicePassword", "acmecorp")) {
            content += $"WithSpecificUser: {WhoAmI(ctx)}<br/>";
        }

        // WithKerberosDelegation: i:0#.w|acmecorp\callingUser
        using (var ctx = factory.WithKerberosDelegation(targetUrl)) {
            content += $"WithKerberosDelegation: {WhoAmI(ctx)}<br/>";

            // If we forget to call Undo, the code that follows continue to
            // execute as the impersonated user.
            factory.ImpersonationContext.Undo();
        }

        // WithDefaultUser: i:0#.w|acmecorp\appPoolUser
        using (var ctx = factory.WithDefaultUser(targetUrl)) {
            content += $"WithDefaultUser: {WhoAmI(ctx)}<br/>";
        }

        return Content(content);
    }
}

As shown, the ClientContext created by WithKerberosDelegation does indeed pass along the calling credentials to the end-system. But we must remember to call Undo to revert to the original credentials or we'll keep executing using the calling credentials. Inside the factory, impersonation happens as a side effect of calling WindowsIdentity.Impersonate. It means that ClientContext instances created by the factory aren't isolated, turning the use of a factory into a bad design choice.

The ClientContext implementation takes advantage of the side effect which happens as a result of calling WindowsIdentity.Impersonate. Inside the Microsoft.SharePoint.Client.Runtime.dll, it's the ClientRuntimeContext's SetupRequestCredential method which determines the outbound credentials used when calling the _vti_bin/client.svc/ProcessQuery endpoint (most CSOM operations takes place against this endpoint). With AuthenticationMode set to Default, not setting the ClientContext's Credentials property during construction, we see that it defaults to CredentialCache.DefaultCredentials:

public class ClientRuntimeContext : IDisposable {
    public static void SetupRequestCredential(ClientRuntimeContext context, HttpWebRequest request) {
        // ...
        } else if (context.AuthenticationMode == ClientAuthenticationMode.Default) {
            if (context.Credentials == null) {
                request.Credentials = CredentialCache.DefaultCredentials;
            } else {
                request.Credentials = context.Credentials;
            }
        }
        // ...
    }
}

According to MSDN, CredentialCache.DefaultCredentials serves the following purpose:

DefaultCredentials represents the system credentials for the current security context in which the application is running. For a client-side application, these are usually the Windows credentials (user name, password, and domain) of the user running the application. For ASP.NET applications, the default credentials are the user credentials of the logged-in user [the application pool account], or the user being impersonated [as set by our code above].

Improved solution

Looking back at AuthenticationTest, observe that by default code is executing using privileged app pool credentials. We have to explicitly transition to the less privileged calling user's credentials. Using the app pool account this way assumes that it's been added to a high-trust security group within the end-system. In fact, even though it may not be required for most operations, the app pool account must be added to a security group representing the maximum privilege level needed across any operation the service is to perform.

With respect to the end-system, if we assume that most calling users are less privileged than the app pool account, executing code under the calling user's credentials and only switch to privileged credentials as needed -- and without interfering with previous credentials -- would be a better design.

By adding constructor code to the base controller, we can make code inside ASP.NET MVC/WebAPI controller actions start executing using the calling user's credentials:

public class AcmeBaseController : Controller {
    // WhoAmI the same as before
  
    public AcmeBaseController() {
        var httpContext = System.Web.HttpContext.Current;
        var identity = httpContext.User.Identity as WindowsIdentity;
        WindowsIdentity.Impersonate(identity.Token);
    }
}

public class HomeController : AcmeBaseController {
    public ActionResult AuthenticationTest(string targetUrl) {
        var content = "";

        // WithDefaultUser: i:0#.w|acmecorp\callingUser
        using (var ctx = new ClientContext(targetUrl)) {                
            content += $"WithDefaultUser: {WhoAmI(ctx)}<br/>";
        }

        // WithSpecificUser: i:0#.w|acmecorp\serviceUser
        using (var ctx = new ClientContext(targetUrl) { Credentials = new NetworkCredential("serviceUser", "servicePassword", "acmeCorp") }) {
            content += $"WithSpecificUser: {WhoAmI(ctx)}<br/>";
        }

        // WithDefaultUser: i:0#.w|acmecorp\callingUser
        using (var ctx = new ClientContext(targetUrl)) {
            content += $"WithDefaultUser: {WhoAmI(ctx)}<br/>";
        }

        return Content(content);
    }
}

Conclusion

Calling the modified AuthenticationTest, code now defaults to run under the calling user's credentials, yet may switch to execute as a specific user.