diff --git a/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs index aebb1abb..74b8c80e 100644 --- a/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs +++ b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs @@ -26,6 +26,27 @@ params Claim[] claims return client; } + /// + /// Same as + /// but lets the caller pass , e.g. to disable + /// auto-redirect when the test asserts on the redirect itself. + /// + public static HttpClient CreateAuthenticatedClient( + this WebApplicationFactory factory, + WebApplicationFactoryClientOptions clientOptions, + params Claim[] claims + ) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(clientOptions); + ArgumentNullException.ThrowIfNull(claims); + + var client = factory.CreateClient(clientOptions); + ApplyClaims(client, claims); + return client; + } + /// /// Convenience overload that adds each entry of /// as a claim before applying any diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs index 0524b3da..b68696d5 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditConfigCacheInvalidator.cs @@ -4,7 +4,7 @@ namespace SimpleModule.AuditLogs.Pipeline; -internal static class AuditConfigCacheInvalidator +public static class AuditConfigCacheInvalidator { public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache) { diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs new file mode 100644 index 00000000..f25ae3af --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictWolverineExtension.cs @@ -0,0 +1,16 @@ +using Wolverine; +using Wolverine.Attributes; + +[assembly: WolverineModule(typeof(SimpleModule.OpenIddict.OpenIddictWolverineExtension))] + +namespace SimpleModule.OpenIddict; + +#pragma warning disable CA1812 // Instantiated by Wolverine via [WolverineModule] +internal sealed class OpenIddictWolverineExtension : IWolverineExtension +#pragma warning restore CA1812 +{ + public void Configure(WolverineOptions options) + { + options.Discovery.IncludeAssembly(typeof(OpenIddictWolverineExtension).Assembly); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs index 765eced4..80728b23 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs @@ -264,7 +264,11 @@ CancellationToken cancellationToken if (row is null) return null; - var appName = await ResolveAppNameAsync(row.Value.ApplicationId, appNameCache, cancellationToken); + var appName = await ResolveAppNameAsync( + row.Value.ApplicationId, + appNameCache, + cancellationToken + ); return new UserSessionDto { @@ -319,7 +323,9 @@ CancellationToken cancellationToken return cached; var app = await appManager.FindByIdAsync(appId, cancellationToken); - var name = app is null ? null : await appManager.GetDisplayNameAsync(app, cancellationToken); + var name = app is null + ? null + : await appManager.GetDisplayNameAsync(app, cancellationToken); cache[appId] = name; return name; } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs new file mode 100644 index 00000000..12e21695 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/UserSignedOutEverywhereHandler.cs @@ -0,0 +1,19 @@ +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.Users.Contracts.Events; + +namespace SimpleModule.OpenIddict.Services; + +/// +/// When the Users module fires "Sign out everywhere", revoke all OpenIddict access and refresh +/// tokens for that user. Bearer/refresh-token holders bypass the cookie SecurityStampValidator, +/// so they need explicit revocation; Users avoids a hard reference to OpenIddict by going +/// through the event bus. +/// +public static class UserSignedOutEverywhereHandler +{ + public static Task Handle( + UserSignedOutEverywhereEvent @event, + IOpenIddictSessionContracts sessions, + CancellationToken cancellationToken + ) => sessions.RevokeAllSessionsForUserAsync(@event.UserId.Value, cancellationToken); +} diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs index d84384fb..4c7520ec 100644 --- a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs +++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs @@ -5,8 +5,8 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; -using SimpleModule.Tests.Shared.Fixtures; using SimpleModule.Testing; +using SimpleModule.Tests.Shared.Fixtures; using SimpleModule.Users.Contracts; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -52,7 +52,10 @@ private async Task SeedUserAsync(string idHint) return userId; } - private HttpClient CreateAuthenticatedNoRedirectClient(string userId, string? currentTokenId = null) + private HttpClient CreateAuthenticatedNoRedirectClient( + string userId, + string? currentTokenId = null + ) { var client = _factory.CreateClient(NoRedirects()); var claims = $"{ClaimTypes.NameIdentifier}={userId}"; @@ -66,12 +69,14 @@ private HttpClient CreateAuthenticatedNoRedirectClient(string userId, string? cu return client; } - private async Task<(string AuthorizationId, string AccessTokenId)> SeedAuthorizationWithTokensAsync( - string userId - ) + private async Task<( + string AuthorizationId, + string AccessTokenId + )> SeedAuthorizationWithTokensAsync(string userId) { using var scope = _factory.Services.CreateScope(); - var authManager = scope.ServiceProvider.GetRequiredService(); + var authManager = + scope.ServiceProvider.GetRequiredService(); var auth = await authManager.CreateAsync( new OpenIddictAuthorizationDescriptor { @@ -171,7 +176,10 @@ public async Task Revoke_WhenTargetSharesCallersAuthorization_Returns400() // the same authorization. var userId = await SeedUserAsync("revoke-self-1"); var (_, accessTokenId) = await SeedAuthorizationWithTokensAsync(userId); - using var client = CreateAuthenticatedNoRedirectClient(userId, currentTokenId: accessTokenId); + using var client = CreateAuthenticatedNoRedirectClient( + userId, + currentTokenId: accessTokenId + ); var response = await client.PostAsync( $"/Identity/Account/Manage/ActiveSessions/{accessTokenId}/revoke", @@ -187,17 +195,19 @@ public async Task Revoke_WhenTargetOwnedByCallerInDifferentAuthorization_Redirec var userId = await SeedUserAsync("revoke-other-auth-1"); var (_, currentToken) = await SeedAuthorizationWithTokensAsync(userId); var (_, otherToken) = await SeedAuthorizationWithTokensAsync(userId); - using var client = CreateAuthenticatedNoRedirectClient(userId, currentTokenId: currentToken); + using var client = CreateAuthenticatedNoRedirectClient( + userId, + currentTokenId: currentToken + ); var response = await client.PostAsync( $"/Identity/Account/Manage/ActiveSessions/{otherToken}/revoke", content: null ); + response.StatusCode.Should().BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response - .StatusCode.Should() - .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); - response.Headers.Location?.ToString() + .Headers.Location?.ToString() .Should() .Contain("/Identity/Account/Manage/ActiveSessions"); } @@ -230,10 +240,9 @@ public async Task RevokeOthers_WhenAuthenticated_RedirectsToListing() content: null ); + response.StatusCode.Should().BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); response - .StatusCode.Should() - .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); - response.Headers.Location?.ToString() + .Headers.Location?.ToString() .Should() .Contain("/Identity/Account/Manage/ActiveSessions"); } diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs index abc16925..84b3bfbc 100644 --- a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs +++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs @@ -26,7 +26,10 @@ public OpenIddictSessionServiceTests(SimpleModuleWebApplicationFactory factory) // ── Seeding ──────────────────────────────────────────────────────── - private static async Task SeedAuthorizationAsync(string userId, IServiceProvider services) + private static async Task SeedAuthorizationAsync( + string userId, + IServiceProvider services + ) { var authManager = services.GetRequiredService(); var auth = await authManager.CreateAsync( @@ -62,8 +65,9 @@ private static async Task SeedTokenAsync( return (await tokenManager.GetIdAsync(token))!; } - private static string NewUserId([System.Runtime.CompilerServices.CallerMemberName] string caller = "") => - $"sess-svc-{caller}-{Guid.NewGuid():N}"; + private static string NewUserId( + [System.Runtime.CompilerServices.CallerMemberName] string caller = "" + ) => $"sess-svc-{caller}-{Guid.NewGuid():N}"; // ── Grouping ─────────────────────────────────────────────────────── @@ -117,7 +121,10 @@ public async Task GetActiveSessions_IsCurrent_SetForBothSiblingsOfTheCallersToke await SeedTokenAsync(userId, authId, TokenTypeHints.RefreshToken, scope.ServiceProvider); var contracts = scope.ServiceProvider.GetRequiredService(); - var sessions = await contracts.GetActiveSessionsForUserAsync(userId, currentTokenId: accessId); + var sessions = await contracts.GetActiveSessionsForUserAsync( + userId, + currentTokenId: accessId + ); sessions.Should().HaveCount(1); sessions[0].IsCurrent.Should().BeTrue(); diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs new file mode 100644 index 00000000..f24d1cf2 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSignedOutEverywhereEvent.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Users.Contracts.Events; + +/// +/// Raised after the user's security stamp has been regenerated by the "Sign out everywhere" +/// action. Subscribers may take additional revocation actions — e.g., OpenIddict revokes any +/// outstanding access/refresh tokens, since bearer holders bypass the cookie-side +/// SecurityStampValidator. +/// +public sealed record UserSignedOutEverywhereEvent(UserId UserId) : IEvent; diff --git a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs index 3d872a2a..178240ed 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs @@ -52,6 +52,7 @@ public static class Routes public const string Disable2fa = "/Manage/Disable2fa"; public const string ResetAuthenticator = "/Manage/ResetAuthenticator"; public const string GenerateRecoveryCodes = "/Manage/GenerateRecoveryCodes"; + public const string SignOutEverywhere = "/Manage/SignOutEverywhere"; } public static class TokenPurposes diff --git a/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs b/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs new file mode 100644 index 00000000..b15c3afd --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/ApplySecurityStampValidatorOptions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Users; + +/// +/// Bridges into ASP.NET Identity's +/// . This controls how quickly a "Sign out everywhere" +/// action propagates to other devices that hold a still-valid cookie. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812:Avoid uninstantiated internal classes", + Justification = "Instantiated by DI" +)] +internal sealed class ApplySecurityStampValidatorOptions(IOptions moduleOptions) + : IPostConfigureOptions +{ + public void PostConfigure(string? name, SecurityStampValidatorOptions options) + { + options.ValidationInterval = moduleOptions.Value.SecurityStampValidationInterval; + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx index 6521e32c..a30d4dec 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx @@ -16,10 +16,17 @@ interface Props { returnUrl: string; showTestAccounts: boolean; passkeyEnabled: boolean; + signedOutEverywhere?: boolean; errors?: { email?: string }; } -export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, errors }: Props) { +export default function Login({ + returnUrl, + showTestAccounts, + passkeyEnabled, + signedOutEverywhere, + errors, +}: Props) { const [passkeyError, setPasskeyError] = useState(null); const [passkeyLoading, setPasskeyLoading] = useState(false); @@ -95,6 +102,11 @@ export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, err + {signedOutEverywhere && ( +
+ Signed out of all devices. Sign in to continue. +
+ )} {errors?.email && (
{errors.email} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs index 34b6b515..7bf30c4c 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/LoginEndpoint.cs @@ -34,7 +34,8 @@ public void Map(IEndpointRouteBuilder app) ISettingsContracts settingsService, ISettingsDefinitionRegistry settingsDefinitions, IOptions passkeyOptions, - [FromQuery] string? returnUrl + [FromQuery] string? returnUrl, + [FromQuery] bool? signedOutEverywhere ) => { await context.SignOutAsync(IdentityConstants.ExternalScheme); @@ -56,6 +57,7 @@ [FromQuery] string? returnUrl passkeyEnabled = !string.IsNullOrEmpty( passkeyOptions.Value.ServerDomain ), + signedOutEverywhere = signedOutEverywhere == true, } ); } diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx index 02bdefbf..36ed63bc 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/ManageIndex.tsx @@ -1,5 +1,18 @@ import { router } from '@inertiajs/react'; -import { Button, Field, FieldGroup, Input, Label } from '@simplemodule/ui'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldGroup, + Input, + Label, +} from '@simplemodule/ui'; +import { useState } from 'react'; import ManageLayout from '@/components/ManageLayout'; interface Props { @@ -9,12 +22,18 @@ interface Props { } export default function ManageIndex({ username, phoneNumber, statusMessage }: Props) { + const [confirmOpen, setConfirmOpen] = useState(false); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); router.post('/Identity/Account/Manage', formData); } + function confirmSignOutEverywhere() { + router.post('/Identity/Account/Manage/SignOutEverywhere'); + } + return (

Profile

@@ -43,6 +62,39 @@ export default function ManageIndex({ username, phoneNumber, statusMessage }: Pr + +
+ +
+

Security

+

+ 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. +

+ +
+ + + + + Sign out everywhere? + + This will sign you out of every device, including this one. You'll need to sign in + again on this device. Continue? + + + + + + + +
); } 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 + ); + } }