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.
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>>() //<-
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With