Impersonation in Identity Server 3

This is just a quick post for my implementation of impersonating a user in Identity Server 3 as a followup from a conversation on github. This code is stripped of all of the config and security within Identity Server and the client application, but it should be pretty easy to plug into whatever you have.

The first thing we need to do is configure Identity Server to detect the request to impersonate a user. Below is a custom custom user service within Identity Server with an override for the PreAuthenticateAsync method. Main bits are looking for an impersonation request, validating user should be impersonated, and then passing back the authentication of the user with some extra claims.

public class CustomUserService : AspNetIdentityUserService<CustomUser, int>
{
    private OwinContext _ctx;

    public CustomUserService(CustomUserManager userMgr, OwinEnvironmentService owinEnv)
        : base(userMgr)
    {
        _ctx = new OwinContext(owinEnv.Environment);
    }

    public override async Task PreAuthenticateAsync(PreAuthenticationContext context)
    {
        //if we are requesting impersonationg, I pass it in acr values as you will see later on (Impersonate:UserName)
        string impersonatedUserName = context.SignInMessage.AcrValues.FirstOrDefault(x => x.Split(':')[0] == "Impersonate");

        if (!string.IsNullOrWhiteSpace(impersonatedUserName))
        {
            impersonatedUserName = impersonatedUserName.Split(':')[1];

            //verify eligibility to impersonate user, you may need to modify this to siut your needs
            PortalUser currentUser = await userManager.FindByIdAsync(int.Parse(_ctx.Authentication.User.FindFirst(Constants.ClaimTypes.Subject).Value));
            bool isUserAdmin = currentUser.Claims.Any(x => x.ClaimType == "UserAdmin");

            //lookup the user to be impersonated
            PortalUser impersonatedUser = await userManager.Users.FirstOrDefaultAsync(x => x.UserName == impersonatedUserName && isUserAdmin);
            if (impersonatedUser != null)
            {
                context.AuthenticateResult = new AuthenticateResult(impersonatedUser.Id.ToString(), impersonatedUser.UserName, impersonationClaims);
            }
            else
            {
                context.AuthenticateResult = new AuthenticateResult("Invalid attempt to impersonate user");
            }
        }
        else
        {
            await base.PreAuthenticateAsync(context);
        }
    }
}

Now that Identity Server knows how to impersonate a user, we need to initiate the impersonation. I put this action in with my user management code and provide a link to it next to each username.

public async Task<ActionResult> Impersonate(string username)
{
    _logger.Info("Impersonate :: Starting, impersonated username: {0}", username);

    //_isUserAdmin is a private variable on the controller that gets set based on if the user is allowed to impersonate this user, adjust to your requirements as necessary
    PortalUser user = await UserManager.Users.FirstOrDefaultAsync(x => x.UserName == username && _isUserAdmin);
    if (user != null)
    {
        _logger.Debug("Impersonate :: User found, sending challenge");

        //I store who we are impersonating in OwinContext for grabbing in the Authentication Notification
        OwinContext.Set("UserToImpersonate", user.UserName);
        return new ChallengeResult(AuthenticationTypes.Federation, ConfigurationManager.AppSettings["ClientApplicationUrl"] + Url.Action("Index", "Home", new { @area = "" }));
    }

    _logger.Debug("Impersonate :: User not found or not allowed, returning");

    return RedirectToAction("Index", "Users", new { @area = "Admin" });
}

If you noticed, we are sending a ChallengeResult back when the impersonation is allowed. I am using UseOpenIdConnectAuthentication in the client application so it triggers a notification and we can process the request over to Identity Server. The first notification will be in the RedirectToIdentityProvider block where we will send out the user to impersonate to Identity Server in the acr values. When Identity Server replies, the AuthorizationCodeReceived notification will run and append in our helper claims.

Notifications = new OpenIdConnectAuthenticationNotifications
{
    AuthorizationCodeReceived = async n =>
    {
        //I removed alot of other code that happens here for brevity, but its basically the same as the sample clients where the user is looked up via OAuth
        
        //This next block just appends some helper claims to know if we are in impersonation mode and who the original user (our user) was
        string currentUserName = n.OwinContext.Authentication.User.FindFirst(Constants.ClaimTypes.PreferredUserName)?.Value;
        if (currentUserName != null && currentUserName != id.FindFirstValue(Constants.ClaimTypes.PreferredUserName))
        {
            //this should only occur if you are impersonating a user (current auth ticket username does not match incoming code)
            id.AddClaim(new Claim("OriginalUserName", currentUserName));
            id.AddClaim(new Claim("IsImpersonating", "true"));
        }

        //Build new auth ticket
        n.AuthenticationTicket = new AuthenticationTicket(new ClaimsIdentity(id.Claims, n.AuthenticationTicket.Identity.AuthenticationType), n.AuthenticationTicket.Properties);
    },
    RedirectToIdentityProvider = async n =>
    {
        if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.AuthenticationRequest)
        {
            //check if we are impersonating a user
            string beginImpersonationModeUser = n.OwinContext.Get<string>("UserNameToImpersonate");
            if (!string.IsNullOrWhiteSpace(beginImpersonationModeUser))
            {
                //send out the username we would like to impersonate to Identity Server
                n.ProtocolMessage.AcrValues = string.Format("{0}:{1}", "Impersonate", beginImpersonationModeUser);
                n.ProtocolMessage.Prompt = Constants.PromptModes.Login;
            }
        }

        await Task.FromResult(0);
    }
}

And that is it, you should be impersonating the other user! To make it easier to go back to being my normal user, I added a link in a banner on the client application that pointed to this action:

public async Task<ActionResult> RevertImpersonation()
{
    //simple as resending a ChallengeResult without the OwinContext keys
    return new ChallengeResult(AuthenticationTypes.Federation, ConfigurationManager.AppSettings["ClientApplicationUrl"] + Url.Action("Index", "Home", new { @area = "" }));
}

If you have any further questions, follow up in the comments and I can answer any questions you may have.