Search code examples
c#asp.net-corewindows-authenticationopenid-connectidentityserver4

Windows Authentication does not accept credentials


I have an Identity Server (ASP.NET Core 2 with Identity Server 4 2.0.0) configured to use Kestrel and IISIntegration, with both Anonymous and Windows authentication enabled on launchSettings.json. I also configured IISOptions like this:

services.Configure<IISOptions>(iis =>
{
    iis.AutomaticAuthentication = false;
    iis.AuthenticationDisplayName = "Windows";
});

services.AddAuthentication();
services.AddCors()
        .AddMvc();
services.AddIdentityServer(); // with AspNetIdentity configured

app.UseAuthentication()
    .UseIdentityServer()
    .UseStaticFiles()
    .UseCors(options => options.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin())
    .UseMvcWithDefaultRoute();

And I have this client (also ASP.NET Core 2 with both Windows and Anonymous authentication enabled, running on Kestrel with IISIntegration)

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddOpenIdConnect(config =>
    {
        config.Authority = "http://localhost:5000";
        config.RequireHttpsMetadata = false;
        config.ClientId = "MyClientId";
        config.ClientSecret = "MyClientSecret";
        config.SaveTokens = true;
        config.GetClaimsFromUserInfoEndpoint = true;
    });

services.AddMvc();

The Identity Server is running on http://localhost:5000 and the client on http://localhost:2040.

When I start the client it correctly presents the Identity Server's login screen, but upon clicking on Windows authentication only keeps asking for credentials. I've looked at the Output Window for both applications and there is no exception raising on either side. I have tried deploying the Identity Server to an IIS server (with Windows Authentication enabled and its pool running under NETWORK SERVICE) and the same behavior is reproduced.


Solution

  • I finally figured it out. I followed the Combined_AspNetIdentity_and_EntityFrameworkStorage quickstart that does not support Windows Authentication. Copying over the relevant code from the 4_ImplicitFlowAuthenticationWithExternal quickstart fixed the issue.

    For simplicity, here is the quickstart code that allows for Windows authentication, modified to use Identity as a user store:

    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ExternalLogin(string provider, string returnUrl = null)
    {
        var props = new AuthenticationProperties()
        {
            RedirectUri = Url.Action("ExternalLoginCallback"),
            Items =
            {
                { "returnUrl", returnUrl },
                { "scheme", AccountOptions.WindowsAuthenticationSchemeName }
            }
        };
    
        // I only care about Windows as an external provider
        var result = await HttpContext.AuthenticateAsync(AccountOptions.WindowsAuthenticationSchemeName);
        if (result?.Principal is WindowsPrincipal wp)
        {
            var id = new ClaimsIdentity(provider);
            id.AddClaim(new Claim(JwtClaimTypes.Subject, wp.Identity.Name));
            id.AddClaim(new Claim(JwtClaimTypes.Name, wp.Identity.Name));
    
            await HttpContext.SignInAsync(
                IdentityServerConstants.ExternalCookieAuthenticationScheme,
                new ClaimsPrincipal(id),
                props);
            return Redirect(props.RedirectUri);
        }
        else
        {
            return Challenge(AccountOptions.WindowsAuthenticationSchemeName);
        }
    }
    
    [HttpGet]
    [AllowAnonymous]
    public async Task<IActionResult> ExternalLoginCallback()
    {
        var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
        if (result?.Succeeded != true)
        {
            throw new Exception("External authentication error");
        }
    
        var externalUser = result.Principal;
        var claims = externalUser.Claims.ToList();
    
        var userIdClaim = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject);
        if (userIdClaim == null)
        {
            userIdClaim = claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
        }
    
        if (userIdClaim == null)
        {
            throw new Exception("Unknown userid");
        }
    
        claims.Remove(userIdClaim);
        string provider = result.Properties.Items["scheme"];
        string userId = userIdClaim.Value;
    
        var additionalClaims = new List<Claim>();
    
        // I changed this to use Identity as a user store
        var user = await userManager.FindByNameAsync(userId);
        if (user == null)
        {
            user = new ApplicationUser
            {
                UserName = userId
            };
    
            var creationResult = await userManager.CreateAsync(user);
    
            if (!creationResult.Succeeded)
            {
                throw new Exception($"Could not create new user: {creationResult.Errors.FirstOrDefault()?.Description}");
            }
        }
        else
        {
            additionalClaims.AddRange(await userManager.GetClaimsAsync(user));
        }
    
        var sid = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
        if (sid != null)
        {
            additionalClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
        }
    
        AuthenticationProperties props = null;
        string id_token = result.Properties.GetTokenValue("id_token");
        if (id_token != null)
        {
            props = new AuthenticationProperties();
            props.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = id_token } });
        }
    
        await HttpContext.SignInAsync(user.Id, user.UserName, provider, props, additionalClaims.ToArray());
    
        await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
    
        string returnUrl = result.Properties.Items["returnUrl"];
        if (_interaction.IsValidReturnUrl(returnUrl) || Url.IsLocalUrl(returnUrl))
        {
            return Redirect(returnUrl);
        }
    
        return Redirect("~/");
    }