8

I'm having a weird problem with an ASP.NET Core 2.1 site. When I sign into it, and refresh it 30 minutes later, I always get this exception thrown:

InvalidOperationException: No sign-out authentication handler is registered for the scheme 'Identity.External'. The registered sign-out schemes are: Identity.Application. Did you forget to call AddAuthentication().AddCookies("Identity.External",...)?

It's correct that I don't have Identity.External registered, but I also don't want it registered. Why does it keep trying to sign it out? Here's how I'm registering my cookie:

services.AddAuthentication(
    o => {
        o.DefaultScheme = IdentityConstants.ApplicationScheme;
    }).AddCookie(IdentityConstants.ApplicationScheme,
    o => {
        o.Events = new CookieAuthenticationEvents {
            OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
        };
    });

services.ConfigureApplicationCookie(
    o => {
        o.Cookie.Expiration = TimeSpan.FromHours(2);
        o.Cookie.HttpOnly = true;
        o.Cookie.SameSite = SameSiteMode.Strict;
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;

        o.AccessDeniedPath = "/admin";
        o.LoginPath = "/admin";
        o.LogoutPath = "/admin/sign-out";
        o.SlidingExpiration = true;
    });

Could someone point me in the right direction on how to resolve this?

UPDATE

Here's the complete code and use process as requested by @Edward in the comments. I'm omitting some parts for brevity.

Startup.cs

public sealed class Startup {
    public void ConfigureServices(
        IServiceCollection services) {
        //  ...
        services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
        services.AddApplicationIdentity();
        services.AddScoped<ApplicationSignInManager>();

        services.Configure<IdentityOptions>(
            o => {
                o.Password.RequiredLength = 8;

                o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                o.Lockout.MaxFailedAccessAttempts = 5;
            });
        services.ConfigureApplicationCookie(
            o => {
                o.Cookie.Name = IdentityConstants.ApplicationScheme;
                o.Cookie.Expiration = TimeSpan.FromHours(2);
                o.Cookie.HttpOnly = true;
                o.Cookie.SameSite = SameSiteMode.Strict;
                o.Cookie.SecurePolicy = CookieSecurePolicy.Always;

                o.AccessDeniedPath = "/admin";
                o.LoginPath = "/admin";
                o.LogoutPath = "/admin/sign-out";
                o.SlidingExpiration = true;
            });
        //  ...
    }

    public void Configure(
        IApplicationBuilder app) {
        //  ...
        app.UseAuthentication();
        //  ...
    }
}

ServiceCollectionExtensions.cs

public static class ServiceCollectionExtensions {
    public static IdentityBuilder AddApplicationIdentity(
        this IServiceCollection services) {
        services.AddAuthentication(
            o => {
                o.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
                o.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
                o.DefaultForbidScheme = IdentityConstants.ApplicationScheme;
                o.DefaultSignInScheme = IdentityConstants.ApplicationScheme;
                o.DefaultSignOutScheme = IdentityConstants.ApplicationScheme;
            }).AddCookie(IdentityConstants.ApplicationScheme,
            o => {
                o.Events = new CookieAuthenticationEvents {
                    OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync
                };
            });

        services.TryAddScoped<SignInManager<User>, ApplicationSignInManager>();
        services.TryAddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
        services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
        services.TryAddScoped<IdentityErrorDescriber>();
        services.TryAddScoped<ISecurityStampValidator, SecurityStampValidator<User>>();
        services.TryAddScoped<IUserClaimsPrincipalFactory<User>, UserClaimsPrincipalFactory<User>>();
        services.TryAddScoped<UserManager<User>>();
        services.TryAddScoped<IUserStore<User>, ApplicationUserStore>();

        return new IdentityBuilder(typeof(User), services);
    }
}

DefaultController.cs

[Area("Admin")]
public sealed class DefaultController :
    AdminControllerBase {
    [HttpPost, AllowAnonymous]
    public async Task<IActionResult> SignIn(
        SignIn.Command command) {
        var result = await Mediator.Send(command);

        if (result.Succeeded) {
            return RedirectToAction("Dashboard", new {
                area = "Admin"
            });
        }

        return RedirectToAction("SignIn", new {
            area = "Admin"
        });
    }

    [HttpGet, ActionName("sign-out")]
    public async Task<IActionResult> SignOut() {
        await Mediator.Send(new SignOut.Command());

        return RedirectToAction("SignIn", new {
            area = "Admin"
        });
    }
}

SignIn.cs

public sealed class SignIn {
    public sealed class Command :
        IRequest<SignInResult> {
        public string Password { get; set; }
        public string Username { get; set; }
    }

    public sealed class CommandHandler :
        HandlerBase<Command, SignInResult> {
        private ApplicationSignInManager SignInManager { get; }

        public CommandHandler(
            DbContext context,
            ApplicationSignInManager signInManager)
            : base(context) {
            SignInManager = signInManager;
        }

        protected override SignInResult Handle(
            Command command) {
            var result = SignInManager.PasswordSignInAsync(command.Username, command.Password, true, false).Result;

            return result;
        }
    }
}

SignOut.cs

public sealed class SignOut {
    public sealed class Command :
        IRequest {
    }

    public sealed class CommandHandler :
        HandlerBase<Command> {
        private ApplicationSignInManager SignInManager { get; }

        public CommandHandler(
            DbContext context,
            ApplicationSignInManager signInManager)
            : base(context) {
            SignInManager = signInManager;
        }

        protected override async void Handle(
            Command command) {
            await SignInManager.SignOutAsync();
        }
    }
}

There's all the relevant code, from how I configure the identity to how I sign in and out. I'm still at a loss of why Identity.External is coming into the picture when I never asked for it.

Technically the SignIn and SignOut classes can be removed and their functionality merged into the DefaultController, however I opt into keeping them to keep the application structure consistent.

4
  • See No authentication handler is configured to handle the scheme. Commented Jul 7, 2018 at 22:33
  • I've come across that post, and it hasn't helped me. One thing I'll note is that I'm using the SignInManager.PasswordSignInAsync method for signing in because I need to check the result and the SignInManager.SingOutAsync for signing out. Commented Jul 8, 2018 at 3:41
  • Could you share us complete code and detail steps to reproduce your issue? Commented Jul 9, 2018 at 9:11
  • @Edward, I've updated my post with the relevant code per your request. Commented Jul 10, 2018 at 16:50

2 Answers 2

9

First off, I’d avoid extending ServiceCollection class. Instead, I would call AddIdetityCore method. Check source code here.

Then:

services.AddIdentityCore<ApplicationUser>()
                .AddUserStore<UserStore>()
                .AddDefaultTokenProviders()
                .AddSignInManager<SignInManager<ApplicationUser>>();

Second, you set up Events property in AddCookie method options. Since you didn’t set up a period of time for the ValidationInterval property, it will last exactly 30 minutes. This means that SecurityStamp property of the user will be verified in the next request the server does, once the time has come to an end. Since in the description you made you didn’t say if you have changed the password, I suspect that user’s SecurityStamp is null in BD while the Cookie version of it is an empty string, so when Identity does the validation between both versions (null == "") it will be false and then Identity would try to close the session of the Application Scheme, the Extern one and also the TwoFactor. Then it will throw the exception because only the ApplicationScheme is registered:

public virtual async Task SignOutAsync()
{
    await Context.SignOutAsync(IdentityConstants.ApplicationScheme);
    await Context.SignOutAsync(IdentityConstants.ExternalScheme); //<- Problem and...
    await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); //... another problem.
}

The solution is first, making sure that SecurityStamp isn’t null. And then you have two options:

Adding the cookies for every scheme

Or

Override SignOutAsync method from SignInManager class.

public class SignInManager<TUser> : Microsoft.AspNetCore.Identity.SignInManager<TUser> 
    where TUser : class
{
    public SignInManager(
        UserManager<TUser> userManager, 
        IHttpContextAccessor contextAccessor, 
        IUserClaimsPrincipalFactory<TUser> claimsFactory, 
        IOptions<IdentityOptions> optionsAccessor, 
        ILogger<Microsoft.AspNetCore.Identity.SignInManager<TUser>> logger, 
        IAuthenticationSchemeProvider schemes) 
        : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes)
    {
    }

    public async override Task SignOutAsync()
    {
        await Context.SignOutAsync(IdentityConstants.ApplicationScheme); // <- 
    }
}

Then:

services.AddIdentityCore<ApplicationUser>()
                .AddUserStore<UserStore>()
                .AddDefaultTokenProviders()
                .AddSignInManager<Services.Infrastructure.Identity.SignInManager<ApplicationUser>>() //<-
Sign up to request clarification or add additional context in comments.

1 Comment

I had forgotten about this questions since it's been so long, but in the end it turned out that I had chosen not to store a security stamp in the database. Perhaps it was because I was going to be the only user and didn't think much of it. Months later, I needed it to work for a real multi-user project so I explored it a little more. I opened a GitHub Issue and finally figured it out with some help: github.com/aspnet/Identity/issues/2082. For now my current setup works, and it's probably different from what I posted above, but I'll check out your recommendation. Thanks!
0

In the end it turned out that I was being dumb and wasn't storing the security stamp in the database. Not really sure why I decided to do that after so long.

Since @Shche reminded of the existence of this post, I decided to give his recommendation a try, partially.

I have since extracted identity configurations into an extension method, and I incorporated @Shche's recommendation about how to add the services in. Here is the extension method:

public static class IdentityExtensions {
    public static IServiceCollection AddApplicationIdentity(
        this IServiceCollection services) {
        services.AddAuthentication(
            o => {
                o.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
                o.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
                o.DefaultSignInScheme = IdentityConstants.ApplicationScheme;
            }).AddCookie(IdentityConstants.ApplicationScheme,
            o => {
                o.Cookie.Expiration = TimeSpan.FromHours(8);
                o.Cookie.SameSite = SameSiteMode.Strict;
                o.Cookie.SecurePolicy = CookieSecurePolicy.Always;

                o.AccessDeniedPath = new PathString("/");
                o.ExpireTimeSpan = TimeSpan.FromHours(8);
                o.LoginPath = new PathString("/sign-in");
                o.LogoutPath = new PathString("/sign-out");
                o.SlidingExpiration = true;
            });

        services.AddIdentityCore<User>(
                    o => {
                        o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
                        o.Lockout.MaxFailedAccessAttempts = 5;

                        o.Password.RequiredLength = 8;
                    })
                .AddSignInManager<ApplicationSignInManager>()
                .AddUserStore<ApplicationUserStore>();

        services.Configure<SecurityStampValidatorOptions>(
            o => {
                o.ValidationInterval = TimeSpan.FromMinutes(1);
            });

        return services;
    }
}

1 Comment

I decided to change my mind and select @Shche's answer instead of mine. The primary reason was because I was revisiting this because I was wondering why can't I just specify the sign out scheme and not override the SignOutAsync(). Well, per @Shche's answer it's because SignOutAsync() doesn't perse care what you've declared to use, it will sign out of all schemes, used or not. That is really the one and only reason why I have to override this one and only method of the built in SignInManager.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.