+ Sign out of every device you've ever signed in on, including this one. Useful if you've
+ lost a device or suspect your account has been compromised.
+
+
+
+
+
);
}
diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs
new file mode 100644
index 00000000..ae5173c4
--- /dev/null
+++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SignOutEverywhereEndpoint.cs
@@ -0,0 +1,60 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Logging;
+using SimpleModule.Core;
+using SimpleModule.Users.Contracts;
+using SimpleModule.Users.Contracts.Events;
+using Wolverine;
+
+namespace SimpleModule.Users.Pages.Account;
+
+public partial class SignOutEverywhereEndpoint : IViewEndpoint
+{
+ public const string Route = UsersConstants.Routes.SignOutEverywhere;
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "User {UserId} invoked sign-out-everywhere; security stamp regenerated."
+ )]
+ private static partial void LogSignedOutEverywhere(ILogger logger, string userId);
+
+ public void Map(IEndpointRouteBuilder app)
+ {
+ app.MapPost(
+ Route,
+ async (
+ ClaimsPrincipal principal,
+ UserManager userManager,
+ SignInManager signInManager,
+ IMessageBus bus,
+ ILogger logger
+ ) =>
+ {
+ var user = await userManager.GetUserAsync(principal);
+ if (user is null)
+ return TypedResults.Redirect("/Identity/Account/Login");
+
+ // Canonical Identity primitive for "invalidate every cookie / token issued
+ // before now". SecurityStampValidator on the cookie middleware checks this
+ // on each request (at the configured interval), so other devices die naturally.
+ await userManager.UpdateSecurityStampAsync(user);
+ await signInManager.SignOutAsync();
+
+ LogSignedOutEverywhere(logger, user.Id);
+
+ // OpenIddict subscribes to this event and revokes any active tokens. Going
+ // through the bus avoids a Users → OpenIddict reference, which the module
+ // graph forbids.
+ await bus.PublishAsync(new UserSignedOutEverywhereEvent(UserId.From(user.Id)));
+
+ return TypedResults.Redirect(
+ "/Identity/Account/Login?signedOutEverywhere=true"
+ );
+ }
+ )
+ .DisableAntiforgery();
+ }
+}
diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs
index d4399c79..a3866047 100644
--- a/modules/Users/src/SimpleModule.Users/UsersModule.cs
+++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs
@@ -77,6 +77,10 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
// Bridge UsersModuleOptions into ASP.NET Identity options
services.AddSingleton, ApplyUsersModuleOptions>();
+ services.AddSingleton<
+ IPostConfigureOptions,
+ ApplySecurityStampValidatorOptions
+ >();
services.AddHostedService();
services.AddSingleton, ConsoleEmailSender>();
diff --git a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs
index 81c30298..6e4f8148 100644
--- a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs
+++ b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs
@@ -41,4 +41,11 @@ public class UsersModuleOptions : IModuleOptions
/// Duration of account lockout after exceeding max failed attempts. Default: 5 minutes.
///
public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// How frequently the cookie middleware revalidates the user's security stamp. The Identity
+ /// default is 30 minutes; we shorten it so a "Sign out everywhere" action takes effect on
+ /// other devices much faster. Default: 1 minute.
+ ///
+ public TimeSpan SecurityStampValidationInterval { get; set; } = TimeSpan.FromMinutes(1);
}
diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs
new file mode 100644
index 00000000..db12c4aa
--- /dev/null
+++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/SignOutEverywhereEndpointTests.cs
@@ -0,0 +1,112 @@
+using System.Net;
+using System.Security.Claims;
+using FluentAssertions;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using SimpleModule.Tests.Shared.Fixtures;
+using SimpleModule.Users.Contracts;
+
+namespace Users.Tests.Integration;
+
+[Collection(TestCollections.Integration)]
+public class SignOutEverywhereEndpointTests
+{
+ private const string EndpointPath = "/Identity/Account/Manage/SignOutEverywhere";
+
+ private static readonly WebApplicationFactoryClientOptions NoRedirect = new()
+ {
+ AllowAutoRedirect = false,
+ };
+
+ private readonly SimpleModuleWebApplicationFactory _factory;
+
+ public SignOutEverywhereEndpointTests(SimpleModuleWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private async Task SeedUserAsync(string id)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+
+ var existing = await userManager.FindByIdAsync(id);
+ if (existing is not null)
+ return existing;
+
+ var user = new ApplicationUser
+ {
+ Id = id,
+ UserName = $"{id}@example.com",
+ Email = $"{id}@example.com",
+ DisplayName = "Sign-out Test User",
+ };
+ var result = await userManager.CreateAsync(user, "TestPass1234!");
+ result.Succeeded.Should().BeTrue();
+ return (await userManager.FindByIdAsync(id))!;
+ }
+
+ private async Task GetSecurityStampAsync(string userId)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var user = await userManager.FindByIdAsync(userId);
+ return user?.SecurityStamp;
+ }
+
+ [Fact]
+ public async Task Post_WhenUnauthenticated_Returns401()
+ {
+ using var client = _factory.CreateClient(NoRedirect);
+
+ var response = await client.PostAsync(EndpointPath, null);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task Post_WhenAuthenticated_RegeneratesSecurityStampAndRedirectsToLogin()
+ {
+ const string userId = "signout-everywhere-user";
+ await SeedUserAsync(userId);
+ var stampBefore = await GetSecurityStampAsync(userId);
+ stampBefore.Should().NotBeNullOrEmpty();
+
+ using var client = _factory.CreateAuthenticatedClient(
+ NoRedirect,
+ new Claim(ClaimTypes.NameIdentifier, userId)
+ );
+
+ var response = await client.PostAsync(EndpointPath, null);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+ response
+ .Headers.Location?.OriginalString.Should()
+ .Contain("signedOutEverywhere=true", "user should land on login with the toast flag");
+
+ var stampAfter = await GetSecurityStampAsync(userId);
+ stampAfter
+ .Should()
+ .NotBeNullOrEmpty()
+ .And.NotBe(
+ stampBefore,
+ "UpdateSecurityStampAsync must change the stamp so existing cookies/tokens "
+ + "stop validating on every other device once SecurityStampValidator runs."
+ );
+ }
+
+ [Fact]
+ public async Task Post_WhenUserDoesNotExist_RedirectsToLogin()
+ {
+ using var client = _factory.CreateAuthenticatedClient(
+ NoRedirect,
+ new Claim(ClaimTypes.NameIdentifier, "nonexistent-signout-user")
+ );
+
+ var response = await client.PostAsync(EndpointPath, null);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found);
+ response.Headers.Location?.OriginalString.Should().Contain("/Identity/Account/Login");
+ }
+}
diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts
index d72482e7..767e4133 100644
--- a/packages/SimpleModule.Client/src/routes.ts
+++ b/packages/SimpleModule.Client/src/routes.ts
@@ -159,6 +159,7 @@ export const routes = {
resetPassword: () => '/Identity/Account/ResetPassword' as const,
sendUnlockEmailConfirmation: () => '/Identity/Account/SendUnlockEmailConfirmation' as const,
sendUnlockEmail: () => '/Identity/Account/SendUnlockEmail' as const,
+ signOutEverywhere: () => '/Identity/Account/Manage/SignOutEverywhere' as const,
twoFactorAuthentication: () => '/Identity/Account/Manage/TwoFactorAuthentication' as const,
unlockAccount: () => '/Identity/Account/UnlockAccount' as const,
changePassword: () => '/Identity/Account/Manage/ChangePassword' as const,
diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs
index ea615a87..876be336 100644
--- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs
+++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using Microsoft.AspNetCore.Mvc.Testing;
using SimpleModule.Testing;
namespace SimpleModule.Tests.Shared.Fixtures;
@@ -23,4 +24,17 @@ public HttpClient CreateAuthenticatedClient(params Claim[] claims)
EnsureDatabasesInitialized();
return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient(this, claims);
}
+
+ public HttpClient CreateAuthenticatedClient(
+ WebApplicationFactoryClientOptions clientOptions,
+ params Claim[] claims
+ )
+ {
+ EnsureDatabasesInitialized();
+ return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient(
+ this,
+ clientOptions,
+ claims
+ );
+ }
}