Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ params Claim[] claims
return client;
}

/// <summary>
/// Same as <see cref="CreateAuthenticatedClient{TEntryPoint}(WebApplicationFactory{TEntryPoint}, Claim[])"/>
/// but lets the caller pass <see cref="WebApplicationFactoryClientOptions"/>, e.g. to disable
/// auto-redirect when the test asserts on the redirect itself.
/// </summary>
public static HttpClient CreateAuthenticatedClient<TEntryPoint>(
this WebApplicationFactory<TEntryPoint> 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;
}

/// <summary>
/// Convenience overload that adds each entry of <paramref name="permissions"/>
/// as a <see cref="WellKnownClaims.Permission"/> claim before applying any
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace SimpleModule.AuditLogs.Pipeline;

internal static class AuditConfigCacheInvalidator
public static class AuditConfigCacheInvalidator
{
public static ValueTask Handle(SettingChangedEvent @event, IFusionCache cache)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using SimpleModule.OpenIddict.Contracts;
using SimpleModule.Users.Contracts.Events;

namespace SimpleModule.OpenIddict.Services;

/// <summary>
/// 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.
/// </summary>
public static class UserSignedOutEverywhereHandler
{
public static Task Handle(
UserSignedOutEverywhereEvent @event,
IOpenIddictSessionContracts sessions,
CancellationToken cancellationToken
) => sessions.RevokeAllSessionsForUserAsync(@event.UserId.Value, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -52,7 +52,10 @@ private async Task<string> 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}";
Expand All @@ -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<IOpenIddictAuthorizationManager>();
var authManager =
scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var auth = await authManager.CreateAsync(
new OpenIddictAuthorizationDescriptor
{
Expand Down Expand Up @@ -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",
Expand All @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ public OpenIddictSessionServiceTests(SimpleModuleWebApplicationFactory factory)

// ── Seeding ────────────────────────────────────────────────────────

private static async Task<string> SeedAuthorizationAsync(string userId, IServiceProvider services)
private static async Task<string> SeedAuthorizationAsync(
string userId,
IServiceProvider services
)
{
var authManager = services.GetRequiredService<IOpenIddictAuthorizationManager>();
var auth = await authManager.CreateAsync(
Expand Down Expand Up @@ -62,8 +65,9 @@ private static async Task<string> 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 ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -117,7 +121,10 @@ public async Task GetActiveSessions_IsCurrent_SetForBothSiblingsOfTheCallersToke
await SeedTokenAsync(userId, authId, TokenTypeHints.RefreshToken, scope.ServiceProvider);

var contracts = scope.ServiceProvider.GetRequiredService<IOpenIddictSessionContracts>();
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using SimpleModule.Core.Events;

namespace SimpleModule.Users.Contracts.Events;

/// <summary>
/// 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.
/// </summary>
public sealed record UserSignedOutEverywhereEvent(UserId UserId) : IEvent;
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;

namespace SimpleModule.Users;

/// <summary>
/// Bridges <see cref="UsersModuleOptions.SecurityStampValidationInterval"/> into ASP.NET Identity's
/// <see cref="SecurityStampValidatorOptions"/>. This controls how quickly a "Sign out everywhere"
/// action propagates to other devices that hold a still-valid cookie.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Performance",
"CA1812:Avoid uninstantiated internal classes",
Justification = "Instantiated by DI"
)]
internal sealed class ApplySecurityStampValidatorOptions(IOptions<UsersModuleOptions> moduleOptions)
: IPostConfigureOptions<SecurityStampValidatorOptions>
{
public void PostConfigure(string? name, SecurityStampValidatorOptions options)
{
options.ValidationInterval = moduleOptions.Value.SecurityStampValidationInterval;
}
}
14 changes: 13 additions & 1 deletion modules/Users/src/SimpleModule.Users/Pages/Account/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [passkeyLoading, setPasskeyLoading] = useState(false);

Expand Down Expand Up @@ -95,6 +102,11 @@ export default function Login({ returnUrl, showTestAccounts, passkeyEnabled, err

<Card>
<CardContent className="p-8">
{signedOutEverywhere && (
<div className="alert-success mb-4 text-sm" role="status">
Signed out of all devices. Sign in to continue.
</div>
)}
{errors?.email && (
<div className="alert-danger mb-4 text-sm" role="alert">
{errors.email}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ public void Map(IEndpointRouteBuilder app)
ISettingsContracts settingsService,
ISettingsDefinitionRegistry settingsDefinitions,
IOptions<IdentityPasskeyOptions> passkeyOptions,
[FromQuery] string? returnUrl
[FromQuery] string? returnUrl,
[FromQuery] bool? signedOutEverywhere
) =>
{
await context.SignOutAsync(IdentityConstants.ExternalScheme);
Expand All @@ -56,6 +57,7 @@ [FromQuery] string? returnUrl
passkeyEnabled = !string.IsNullOrEmpty(
passkeyOptions.Value.ServerDomain
),
signedOutEverywhere = signedOutEverywhere == true,
}
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,12 +22,18 @@ interface Props {
}

export default function ManageIndex({ username, phoneNumber, statusMessage }: Props) {
const [confirmOpen, setConfirmOpen] = useState(false);

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
router.post('/Identity/Account/Manage', formData);
}

function confirmSignOutEverywhere() {
router.post('/Identity/Account/Manage/SignOutEverywhere');
}

return (
<ManageLayout activePage="Index">
<h3 className="text-xl font-bold mb-4">Profile</h3>
Expand Down Expand Up @@ -43,6 +62,39 @@ export default function ManageIndex({ username, phoneNumber, statusMessage }: Pr
</Button>
</FieldGroup>
</form>

<hr className="my-8 border-border" />

<section>
<h3 className="text-xl font-bold mb-2">Security</h3>
<p className="text-sm text-text-secondary mb-4">
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.
</p>
<Button type="button" variant="danger" onClick={() => setConfirmOpen(true)}>
Sign out everywhere
</Button>
</section>

<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign out everywhere?</DialogTitle>
<DialogDescription>
This will sign you out of every device, including this one. You'll need to sign in
again on this device. Continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={confirmSignOutEverywhere}>
Sign out everywhere
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ManageLayout>
);
}
Loading
Loading