From 22009df52db7f0752765109296611dc5456fc610 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 20:47:21 +0200 Subject: [PATCH 1/3] feat(openiddict): user-facing active sessions page (#178) Adds /Identity/Account/Manage/ActiveSessions so users can see and revoke their own OpenIddict sessions, including a "Sign out of all other devices" action. The endpoints live in the OpenIddict module (it already owns the session contract and references Users.Contracts; hosting them in Users would close the cycle SM0010 forbids). The React page is reached from the Users ManageLayout nav. - IOpenIddictSessionContracts gains TryRevokeSessionForUserAsync (single-load ownership-checked revoke), the IsCurrent flag on UserSessionDto, and RevokeOtherSessionsForUserAsync; RevokeAll delegates to RevokeOther(currentTokenId: null). - Endpoint ownership guard returns 404 (not 403) so the response shape doesn't leak whether a token id exists for another user. - Self-revoke is rejected before touching the store. --- .../IOpenIddictSessionContracts.cs | 25 +++ .../UserSessionDto.cs | 1 + .../Pages/Account/Manage/ActiveSessions.tsx | 132 ++++++++++++++ .../ActiveSessions/ActiveSessionsEndpoint.cs | 47 +++++ .../ActiveSessions/ActiveSessionsHelpers.cs | 19 ++ .../RevokeOtherSessionsEndpoint.cs | 40 +++++ .../ActiveSessions/RevokeSessionEndpoint.cs | 63 +++++++ .../SimpleModule.OpenIddict/Pages/index.ts | 1 + .../Services/OpenIddictSessionService.cs | 54 +++++- .../src/SimpleModule.OpenIddict/types.ts | 1 + .../ActiveSessionsEndpointTests.cs | 162 ++++++++++++++++++ .../components/ManageLayout.tsx | 6 + packages/SimpleModule.Client/src/routes.ts | 16 +- 13 files changed, 554 insertions(+), 13 deletions(-) create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsHelpers.cs create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs create mode 100644 modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs index 1b4321a2..2ddd427a 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs @@ -7,10 +7,35 @@ Task> GetActiveSessionsForUserAsync( CancellationToken cancellationToken = default ); + Task> GetActiveSessionsForUserAsync( + string userId, + string? currentTokenId, + CancellationToken cancellationToken = default + ); + + /// + /// Revokes the token if and only if its subject equals . + /// Returns true on revoke, false if the token does not exist or belongs to a + /// different user. Single round-trip — used by the user-facing revoke endpoint + /// to defend against cross-user token-id guessing without a separate ownership + /// query. + /// + Task TryRevokeSessionForUserAsync( + string tokenId, + string userId, + CancellationToken cancellationToken = default + ); + Task RevokeSessionAsync(string tokenId, CancellationToken cancellationToken = default); Task RevokeAllSessionsForUserAsync( string userId, CancellationToken cancellationToken = default ); + + Task RevokeOtherSessionsForUserAsync( + string userId, + string? currentTokenId, + CancellationToken cancellationToken = default + ); } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs index 3138bf22..f2516089 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs @@ -10,4 +10,5 @@ public class UserSessionDto public string? ApplicationName { get; set; } public DateTimeOffset? CreationDate { get; set; } public DateTimeOffset? ExpirationDate { get; set; } + public bool IsCurrent { get; set; } } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx new file mode 100644 index 00000000..4eff9f36 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx @@ -0,0 +1,132 @@ +import { router } from '@inertiajs/react'; +import { routes } from '@simplemodule/client/routes'; +import { + Badge, + Button, + Card, + CardContent, + PageShell, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; + +interface Session { + tokenId: string; + type: string; + applicationName: string | null; + creationDate: string | null; + expirationDate: string | null; + isCurrent: boolean; +} + +interface Props { + sessions: Session[]; +} + +const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; + +export default function ActiveSessions({ sessions }: Props) { + const hasOtherSessions = sessions.some((s) => !s.isCurrent); + const staleBefore = Date.now() - STALE_THRESHOLD_MS; + + function handleRevoke(tokenId: string) { + router.post(routes.openIddict.api.revokeSession(tokenId)); + } + + function handleRevokeOthers() { + router.post(routes.openIddict.api.revokeOtherSessions()); + } + + return ( + + + + {hasOtherSessions && ( +
+ +
+ )} + {sessions.length === 0 ? ( +

No active sessions.

+ ) : ( +
+ + + + Type + Application + Created + Expires + + + + + {sessions.map((session) => { + const created = session.creationDate + ? new Date(session.creationDate) + : null; + const isStale = + !session.isCurrent && + created !== null && + created.getTime() < staleBefore; + return ( + + +
+ + {session.type === 'refresh_token' ? 'Refresh' : 'Access'} + + {session.isCurrent && This device} + {isStale && Stale} +
+
+ + {session.applicationName || '—'} + + + {created ? created.toLocaleString() : '—'} + + + {session.expirationDate + ? new Date(session.expirationDate).toLocaleString() + : 'Never'} + + + {!session.isCurrent && ( + + )} + +
+ ); + })} +
+
+
+ )} +
+
+
+ ); +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs new file mode 100644 index 00000000..1f7c83b0 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Extensions; +using SimpleModule.Core.Inertia; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.OpenIddict.Pages.OpenIddict.ActiveSessions; + +public class ActiveSessionsEndpoint : IEndpoint +{ + public const string Route = "/Identity/Account/Manage/ActiveSessions"; + public const string Method = "GET"; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + Route, + async Task ( + ClaimsPrincipal principal, + IOpenIddictSessionContracts sessionContracts + ) => + { + var userId = principal.GetUserId(); + if (string.IsNullOrEmpty(userId)) + { + return TypedResults.Redirect("/Identity/Account/Login"); + } + + var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); + var sessions = await sessionContracts.GetActiveSessionsForUserAsync( + userId, + currentTokenId + ); + + return Inertia.Render( + "OpenIddict/Account/Manage/ActiveSessions", + new { sessions } + ); + } + ) + .RequireAuthorization() + .ExcludeFromDescription(); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsHelpers.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsHelpers.cs new file mode 100644 index 00000000..988e799b --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsHelpers.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; + +namespace SimpleModule.OpenIddict.Pages.OpenIddict.ActiveSessions; + +internal static class ActiveSessionsHelpers +{ + // OpenIddict's validation handler exposes the originating token id on the + // principal as a private claim. For cookie-authenticated requests the claim + // is absent, which is fine — no OpenIddict session will match the browser + // cookie and every listed session will remain revocable. + private const string AccessTokenIdClaim = "oi_tkn_id"; + private const string RefreshTokenIdClaim = "oi_reft_id"; + + public static string? GetCurrentTokenId(ClaimsPrincipal principal) + { + return principal.FindFirstValue(AccessTokenIdClaim) + ?? principal.FindFirstValue(RefreshTokenIdClaim); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs new file mode 100644 index 00000000..18308f2f --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Extensions; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.OpenIddict.Pages.OpenIddict.ActiveSessions; + +public class RevokeOtherSessionsEndpoint : IEndpoint +{ + public const string Route = "/Identity/Account/Manage/ActiveSessions/revoke-others"; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async Task ( + ClaimsPrincipal principal, + IOpenIddictSessionContracts sessionContracts + ) => + { + var userId = principal.GetUserId(); + if (string.IsNullOrEmpty(userId)) + { + return TypedResults.Unauthorized(); + } + + var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); + await sessionContracts.RevokeOtherSessionsForUserAsync(userId, currentTokenId); + return TypedResults.Redirect("/Identity/Account/Manage/ActiveSessions"); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .ExcludeFromDescription(); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs new file mode 100644 index 00000000..7bb7f520 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs @@ -0,0 +1,63 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Extensions; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.OpenIddict.Pages.OpenIddict.ActiveSessions; + +public class RevokeSessionEndpoint : IEndpoint +{ + public const string Route = "/Identity/Account/Manage/ActiveSessions/{tokenId}/revoke"; + public const string Method = "POST"; + + public void Map(IEndpointRouteBuilder app) + { + app.MapPost( + Route, + async Task ( + string tokenId, + ClaimsPrincipal principal, + IOpenIddictSessionContracts sessionContracts + ) => + { + var userId = principal.GetUserId(); + if (string.IsNullOrEmpty(userId)) + { + return TypedResults.Unauthorized(); + } + + // Refuse to revoke the request's own session before touching + // the store, so a self-revoke can never silently sign the + // caller out from under their own request. + var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); + if ( + !string.IsNullOrEmpty(currentTokenId) + && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal) + ) + { + return TypedResults.BadRequest(); + } + + // 404 (not 403) when the token is missing or owned by someone + // else, so the response shape doesn't leak whether a token id + // exists for a different user. + var revoked = await sessionContracts.TryRevokeSessionForUserAsync( + tokenId, + userId + ); + if (!revoked) + { + return TypedResults.NotFound(); + } + + return TypedResults.Redirect("/Identity/Account/Manage/ActiveSessions"); + } + ) + .RequireAuthorization() + .DisableAntiforgery() + .ExcludeFromDescription(); + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/index.ts b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/index.ts index a7ad2289..1683e039 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/index.ts +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/index.ts @@ -3,4 +3,5 @@ 'OpenIddict/OpenIddict/ClientsCreate': () => import('./ClientsCreate'), 'OpenIddict/OpenIddict/ClientsEdit': () => import('./ClientsEdit'), 'OpenIddict/OAuthCallback': () => import('./OAuthCallback'), + 'OpenIddict/Account/Manage/ActiveSessions': () => import('./Account/Manage/ActiveSessions'), }; diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs index b4bdb6be..ed0e08e6 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs @@ -9,8 +9,14 @@ public sealed class OpenIddictSessionService( IOpenIddictApplicationManager appManager ) : IOpenIddictSessionContracts { + public Task> GetActiveSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ) => GetActiveSessionsForUserAsync(userId, currentTokenId: null, cancellationToken); + public async Task> GetActiveSessionsForUserAsync( string userId, + string? currentTokenId, CancellationToken cancellationToken = default ) { @@ -44,11 +50,13 @@ public async Task> GetActiveSessionsForUserAsync( } } + var tokenId = + await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty; + sessions.Add( new UserSessionDto { - TokenId = - await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty, + TokenId = tokenId, Type = type ?? string.Empty, ApplicationName = appName, CreationDate = await tokenManager.GetCreationDateAsync( @@ -56,6 +64,9 @@ await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty, cancellationToken ), ExpirationDate = expiration, + IsCurrent = + !string.IsNullOrEmpty(currentTokenId) + && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal), } ); } @@ -63,6 +74,24 @@ await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty, return sessions; } + public async Task TryRevokeSessionForUserAsync( + string tokenId, + string userId, + CancellationToken cancellationToken = default + ) + { + var token = await tokenManager.FindByIdAsync(tokenId, cancellationToken); + if (token is null) + return false; + + var subject = await tokenManager.GetSubjectAsync(token, cancellationToken); + if (!string.Equals(subject, userId, StringComparison.Ordinal)) + return false; + + await tokenManager.TryRevokeAsync(token, cancellationToken); + return true; + } + public async Task RevokeSessionAsync( string tokenId, CancellationToken cancellationToken = default @@ -75,20 +104,35 @@ public async Task RevokeSessionAsync( } } - public async Task RevokeAllSessionsForUserAsync( + public Task RevokeAllSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ) => RevokeOtherSessionsForUserAsync(userId, currentTokenId: null, cancellationToken); + + public async Task RevokeOtherSessionsForUserAsync( string userId, + string? currentTokenId, CancellationToken cancellationToken = default ) { + // Materialize valid tokens first; revoking inside the FindBySubjectAsync + // enumeration could mutate the underlying store mid-iteration. var tokensToRevoke = new List(); await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) { var status = await tokenManager.GetStatusAsync(token, cancellationToken); - if (status == Statuses.Valid) + if (status != Statuses.Valid) + continue; + + if (!string.IsNullOrEmpty(currentTokenId)) { - tokensToRevoke.Add(token); + var tokenId = await tokenManager.GetIdAsync(token, cancellationToken); + if (string.Equals(tokenId, currentTokenId, StringComparison.Ordinal)) + continue; } + + tokensToRevoke.Add(token); } foreach (var token in tokensToRevoke) diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts index ca46ab2a..eb62413a 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts @@ -5,6 +5,7 @@ export interface UserSessionDto { applicationName: string; creationDate: string | null; expirationDate: string | null; + isCurrent: boolean; } export interface OpenIddictPermissions { diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs new file mode 100644 index 00000000..f62080ba --- /dev/null +++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs @@ -0,0 +1,162 @@ +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.Testing; +using SimpleModule.Users.Contracts; + +namespace OpenIddict.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class ActiveSessionsEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ActiveSessionsEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + private static WebApplicationFactoryClientOptions NoRedirects() => + new() { AllowAutoRedirect = false }; + + private async Task SeedUserAsync(string idHint) + { + // Ensures module databases are created (the factory's instance method + // does this lazily on first use). Required before resolving UserManager + // because the OpenIddict.Tests project doesn't share the Users.Tests + // seeding path. + using (_factory.CreateAuthenticatedClient()) { } + + using var scope = _factory.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var userId = $"active-sessions-{idHint}"; + var existing = await userManager.FindByIdAsync(userId); + if (existing is not null) + return userId; + + var user = new ApplicationUser + { + Id = userId, + UserName = $"{userId}@example.com", + Email = $"{userId}@example.com", + DisplayName = $"Test User {idHint}", + }; + await userManager.CreateAsync(user, "TestPass1234!"); + return userId; + } + + private HttpClient CreateAuthenticatedNoRedirectClient(string userId) + { + var client = _factory.CreateClient(NoRedirects()); + client.DefaultRequestHeaders.Add( + TestAuthDefaults.ClaimsHeader, + $"{ClaimTypes.NameIdentifier}={userId}" + ); + return client; + } + + // ── GET page ─────────────────────────────────────────────────────── + + [Fact] + public async Task Get_WhenAuthenticated_Returns200() + { + var userId = await SeedUserAsync("get-1"); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.GetAsync("/Identity/Account/Manage/ActiveSessions"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Get_WhenUnauthenticated_RedirectsToLogin() + { + var client = _factory.CreateClient(NoRedirects()); + + var response = await client.GetAsync("/Identity/Account/Manage/ActiveSessions"); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found, HttpStatusCode.Unauthorized); + } + + // ── POST revoke single ───────────────────────────────────────────── + + [Fact] + public async Task Revoke_WhenUnauthenticated_RedirectsOrUnauthorized() + { + var client = _factory.CreateClient(NoRedirects()); + + var response = await client.PostAsync( + "/Identity/Account/Manage/ActiveSessions/some-token/revoke", + content: null + ); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Redirect, HttpStatusCode.Found); + } + + [Fact] + public async Task Revoke_WhenSessionDoesNotBelongToCaller_Returns404() + { + // The shared in-memory database has no OpenIddict tokens for this user, so + // any token id is treated as "not owned by the caller" → 404. Defends + // against the cross-user attack where an attacker guesses someone else's + // token id. + var userId = await SeedUserAsync("revoke-1"); + var client = _factory.CreateAuthenticatedClient( + new Claim(ClaimTypes.NameIdentifier, userId) + ); + + var response = await client.PostAsync( + "/Identity/Account/Manage/ActiveSessions/someone-elses-token-id/revoke", + content: null + ); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── POST revoke-others ───────────────────────────────────────────── + + [Fact] + public async Task RevokeOthers_WhenUnauthenticated_RedirectsOrUnauthorized() + { + var client = _factory.CreateClient(NoRedirects()); + + var response = await client.PostAsync( + "/Identity/Account/Manage/ActiveSessions/revoke-others", + content: null + ); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Redirect, HttpStatusCode.Found); + } + + [Fact] + public async Task RevokeOthers_WhenAuthenticated_RedirectsToListing() + { + var userId = await SeedUserAsync("revoke-others-1"); + using var client = CreateAuthenticatedNoRedirectClient(userId); + + var response = await client.PostAsync( + "/Identity/Account/Manage/ActiveSessions/revoke-others", + content: null + ); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); + response.Headers.Location?.ToString() + .Should() + .Contain("/Identity/Account/Manage/ActiveSessions"); + } +} diff --git a/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx b/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx index a787673e..e3a4948b 100644 --- a/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx +++ b/modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx @@ -42,6 +42,12 @@ const navItems = [ label: 'Personal data', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', }, + { + href: '/Identity/Account/Manage/ActiveSessions', + page: 'ActiveSessions', + label: 'Active sessions', + icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + }, ]; function NavLink({ diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 0707b4da..43db579c 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -88,19 +88,16 @@ export const routes = { }, tenants: { api: { - deleteTenantFeature: (id: string | number, flagName: string | number) => - `/api/tenants/${id}/features/${flagName}`, + deleteTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, getTenantFeatures: (id: string | number) => `/api/tenants/${id}/features`, - setTenantFeature: (id: string | number, flagName: string | number) => - `/api/tenants/${id}/features/${flagName}`, + setTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, addHost: (id: string | number) => `/api/tenants/${id}/hosts`, changeStatus: (id: string | number) => `/api/tenants/${id}/status`, create: () => '/api/tenants' as const, delete: (id: string | number) => `/api/tenants/${id}`, getAll: () => '/api/tenants' as const, getById: (id: string | number) => `/api/tenants/${id}`, - removeHost: (id: string | number, hostId: string | number) => - `/api/tenants/${id}/hosts/${hostId}`, + removeHost: (id: string | number, hostId: string | number) => `/api/tenants/${id}/hosts/${hostId}`, update: (id: string | number) => `/api/tenants/${id}`, }, views: { @@ -195,6 +192,9 @@ export const routes = { oAuthCallback: () => '/oauth-callback' as const, token: () => '/connect/token' as const, userinfo: () => '/connect/userinfo' as const, + activeSessions: () => '/Identity/Account/Manage/ActiveSessions' as const, + revokeOtherSessions: () => '/Identity/Account/Manage/ActiveSessions/revoke-others' as const, + revokeSession: (tokenId: string | number) => `/Identity/Account/Manage/ActiveSessions/${tokenId}/revoke`, }, views: { clientsCreate: () => '/openiddict/clients/create' as const, @@ -205,8 +205,7 @@ export const routes = { admin: { api: { adminRoles: () => '/admin/roles' as const, - adminSessions: (id: string | number, tokenId: string | number) => - `/admin/users/${id}/sessions/${tokenId}`, + adminSessions: (id: string | number, tokenId: string | number) => `/admin/users/${id}/sessions/${tokenId}`, adminUsers: () => '/admin/users' as const, }, views: { @@ -220,3 +219,4 @@ export const routes = { }, }, } as const; + From a82a2c4148b4f9e82d61aa52c1a5304a5a003cd7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 21:44:00 +0200 Subject: [PATCH 2/3] fix(openiddict): group active sessions by authorization and add tests (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing /Identity/Account/Manage/ActiveSessions page listed access and refresh tokens as independent rows. A user could revoke the refresh token for their own session while keeping the access token live, then lose the session on the next refresh — the self-revoke guard only compared token ids, not authorizations. - IOpenIddictSessionContracts: TryRevokeSessionForUserAsync now takes currentTokenId and returns a RevokeSessionResult discriminated union (Revoked / NotFound / BlockedCurrent), so the endpoint doesn't have to know about authorization-id semantics. - OpenIddictSessionService: the user-facing GetActiveSessionsForUserAsync overload groups tokens by AuthorizationId and emits one row per authorization (refresh-token anchored when present). IsCurrent is set whenever the group contains the caller's token. Revoke now operates on the whole authorization, and RevokeOther excludes by authorization rather than by token id. Admin's flat overload is unchanged. - Page wraps in a local ManageLayout so the /Manage/* side nav renders. Cross-module React imports aren't possible in Vite library mode and SM0010 forbids OpenIddict importing from Users, so the layout is duplicated with a keep-in-sync comment. - Endpoint handlers drop the unreachable userId-null branches that ran after RequireAuthorization had already short-circuited. - New tests cover pair grouping, multi-authorization listing, IsCurrent across siblings, self-revoke blocked, sibling revoke on success, cross-user 404, unknown-token 404, RevokeOther preserves current authorization, and RevokeOther with null wipes everything. Endpoint tests gain self-revoke (400) and cross-authorization revoke (redirect) cases. --- .../IOpenIddictSessionContracts.cs | 45 ++- .../Pages/Account/Manage/ActiveSessions.tsx | 159 ++++---- .../ActiveSessions/ActiveSessionsEndpoint.cs | 7 +- .../RevokeOtherSessionsEndpoint.cs | 7 +- .../ActiveSessions/RevokeSessionEndpoint.cs | 44 +-- .../Services/OpenIddictSessionService.cs | 293 ++++++++++++--- .../components/ManageLayout.tsx | 124 +++++++ .../ActiveSessionsEndpointTests.cs | 88 ++++- .../OpenIddictSessionServiceTests.cs | 349 ++++++++++++++++++ 9 files changed, 938 insertions(+), 178 deletions(-) create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/components/ManageLayout.tsx create mode 100644 modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs index 2ddd427a..383c489f 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs @@ -1,12 +1,39 @@ namespace SimpleModule.OpenIddict.Contracts; +public enum RevokeSessionResult +{ + /// The session existed, was owned by the caller, and has been revoked. + Revoked, + + /// The token id was unknown or belonged to a different user. The endpoint + /// surfaces this as 404 so the response shape doesn't leak whether a token id + /// exists for someone else. + NotFound, + + /// The token is part of the caller's own session (shares an authorization + /// with the request's token). Refused to prevent self-lockout. + BlockedCurrent, +} + public interface IOpenIddictSessionContracts { + /// + /// Returns one row per valid token. Used by the admin tab where each token + /// (access / refresh / rotation) is shown individually. + /// Task> GetActiveSessionsForUserAsync( string userId, CancellationToken cancellationToken = default ); + /// + /// Returns one row per authorization (i.e. per login). Tokens sharing an + /// AuthorizationId collapse to a single "session" entry so the user can't + /// revoke their refresh token while leaving their access token live, or + /// vice versa. The DTO's TokenId is the anchor token id used for + /// subsequent revoke calls; IsCurrent is set when the group contains + /// . + /// Task> GetActiveSessionsForUserAsync( string userId, string? currentTokenId, @@ -14,15 +41,16 @@ Task> GetActiveSessionsForUserAsync( ); /// - /// Revokes the token if and only if its subject equals . - /// Returns true on revoke, false if the token does not exist or belongs to a - /// different user. Single round-trip — used by the user-facing revoke endpoint - /// to defend against cross-user token-id guessing without a separate ownership - /// query. + /// Revokes the authorization containing , but only + /// if it belongs to and does not share an + /// authorization with . Returns a result the + /// endpoint maps to 200 / 400 / 404. Single-load ownership check defends + /// against cross-user token-id guessing without a separate query. /// - Task TryRevokeSessionForUserAsync( + Task TryRevokeSessionForUserAsync( string tokenId, string userId, + string? currentTokenId, CancellationToken cancellationToken = default ); @@ -33,6 +61,11 @@ Task RevokeAllSessionsForUserAsync( CancellationToken cancellationToken = default ); + /// + /// Revokes every valid token for the user except those sharing an authorization + /// with . When + /// is null, revokes everything (equivalent to ). + /// Task RevokeOtherSessionsForUserAsync( string userId, string? currentTokenId, diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx index 4eff9f36..96025a62 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/Account/Manage/ActiveSessions.tsx @@ -3,9 +3,6 @@ import { routes } from '@simplemodule/client/routes'; import { Badge, Button, - Card, - CardContent, - PageShell, Table, TableBody, TableCell, @@ -13,6 +10,7 @@ import { TableHeader, TableRow, } from '@simplemodule/ui'; +import ManageLayout from '@/components/ManageLayout'; interface Session { tokenId: string; @@ -42,91 +40,80 @@ export default function ActiveSessions({ sessions }: Props) { } return ( - - - + +
+
+
+

Active sessions

+

+ Apps and devices that are currently signed in to your account. +

+
{hasOtherSessions && ( -
- -
+ )} - {sessions.length === 0 ? ( -

No active sessions.

- ) : ( -
- - - - Type - Application - Created - Expires - - - - - {sessions.map((session) => { - const created = session.creationDate - ? new Date(session.creationDate) - : null; - const isStale = - !session.isCurrent && - created !== null && - created.getTime() < staleBefore; - return ( - - -
- - {session.type === 'refresh_token' ? 'Refresh' : 'Access'} - - {session.isCurrent && This device} - {isStale && Stale} -
-
- - {session.applicationName || '—'} - - - {created ? created.toLocaleString() : '—'} - - - {session.expirationDate - ? new Date(session.expirationDate).toLocaleString() - : 'Never'} - - - {!session.isCurrent && ( - + + {sessions.length === 0 ? ( +

No active sessions.

+ ) : ( +
+
+ + + Status + Application + Created + Expires + + + + + {sessions.map((session) => { + const created = session.creationDate ? new Date(session.creationDate) : null; + const isStale = + !session.isCurrent && created !== null && created.getTime() < staleBefore; + return ( + + +
+ {session.isCurrent ? ( + This device + ) : ( + Active )} - - - ); - })} - -
-
- )} - - - + {isStale && Stale} +
+ + {session.applicationName || '—'} + + {created ? created.toLocaleString() : '—'} + + + {session.expirationDate + ? new Date(session.expirationDate).toLocaleString() + : 'Never'} + + + {!session.isCurrent && ( + + )} + + + ); + })} + + +
+ )} + +
); } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs index 1f7c83b0..d2d80acb 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/ActiveSessionsEndpoint.cs @@ -23,12 +23,7 @@ async Task ( IOpenIddictSessionContracts sessionContracts ) => { - var userId = principal.GetUserId(); - if (string.IsNullOrEmpty(userId)) - { - return TypedResults.Redirect("/Identity/Account/Login"); - } - + var userId = principal.GetUserId()!; var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); var sessions = await sessionContracts.GetActiveSessionsForUserAsync( userId, diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs index 18308f2f..a3d2edef 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeOtherSessionsEndpoint.cs @@ -22,12 +22,7 @@ async Task ( IOpenIddictSessionContracts sessionContracts ) => { - var userId = principal.GetUserId(); - if (string.IsNullOrEmpty(userId)) - { - return TypedResults.Unauthorized(); - } - + var userId = principal.GetUserId()!; var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); await sessionContracts.RevokeOtherSessionsForUserAsync(userId, currentTokenId); return TypedResults.Redirect("/Identity/Account/Manage/ActiveSessions"); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs index 7bb7f520..06ace0a0 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs @@ -23,37 +23,29 @@ async Task ( IOpenIddictSessionContracts sessionContracts ) => { - var userId = principal.GetUserId(); - if (string.IsNullOrEmpty(userId)) - { - return TypedResults.Unauthorized(); - } - - // Refuse to revoke the request's own session before touching - // the store, so a self-revoke can never silently sign the - // caller out from under their own request. + // RequireAuthorization short-circuits unauthenticated requests + // before the handler runs, so userId is always present. + var userId = principal.GetUserId()!; var currentTokenId = ActiveSessionsHelpers.GetCurrentTokenId(principal); - if ( - !string.IsNullOrEmpty(currentTokenId) - && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal) - ) - { - return TypedResults.BadRequest(); - } - // 404 (not 403) when the token is missing or owned by someone - // else, so the response shape doesn't leak whether a token id - // exists for a different user. - var revoked = await sessionContracts.TryRevokeSessionForUserAsync( + var result = await sessionContracts.TryRevokeSessionForUserAsync( tokenId, - userId + userId, + currentTokenId ); - if (!revoked) - { - return TypedResults.NotFound(); - } - return TypedResults.Redirect("/Identity/Account/Manage/ActiveSessions"); + return result switch + { + // Self-revoke is rejected with 400 — revoking the + // caller's own session would sign them out from under + // their own request. + RevokeSessionResult.BlockedCurrent => TypedResults.BadRequest(), + // 404 (not 403) when the token is missing or owned by + // someone else, so the response shape doesn't leak + // whether a token id exists for a different user. + RevokeSessionResult.NotFound => TypedResults.NotFound(), + _ => TypedResults.Redirect("/Identity/Account/Manage/ActiveSessions"), + }; } ) .RequireAuthorization() diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs index ed0e08e6..765eced4 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs @@ -9,10 +9,23 @@ public sealed class OpenIddictSessionService( IOpenIddictApplicationManager appManager ) : IOpenIddictSessionContracts { - public Task> GetActiveSessionsForUserAsync( + public async Task> GetActiveSessionsForUserAsync( string userId, CancellationToken cancellationToken = default - ) => GetActiveSessionsForUserAsync(userId, currentTokenId: null, cancellationToken); + ) + { + var sessions = new List(); + var appNameCache = new Dictionary(); + + await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) + { + var dto = await BuildDtoAsync(token, appNameCache, cancellationToken); + if (dto is not null) + sessions.Add(dto); + } + + return sessions; + } public async Task> GetActiveSessionsForUserAsync( string userId, @@ -20,53 +33,83 @@ public async Task> GetActiveSessionsForUserAsync( CancellationToken cancellationToken = default ) { - var sessions = new List(); + // Resolve the caller's authorization id once so we can flag the matching + // group as current, even if the rendered anchor is a sibling token. + var currentAuthorizationId = await GetAuthorizationIdForTokenAsync( + currentTokenId, + cancellationToken + ); var appNameCache = new Dictionary(); + // Collect valid tokens grouped by authorization. Tokens with no + // authorization id (rare — non-code grants) stand alone keyed by their + // own id so they still get a row. + var groups = new Dictionary>(StringComparer.Ordinal); + await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) { - var type = await tokenManager.GetTypeAsync(token, cancellationToken); - if (type is not (TokenTypeHints.AccessToken or TokenTypeHints.RefreshToken)) - continue; - - var status = await tokenManager.GetStatusAsync(token, cancellationToken); - if (status != Statuses.Valid) + var row = await ReadTokenAsync(token, cancellationToken); + if (row is null) continue; - var expiration = await tokenManager.GetExpirationDateAsync(token, cancellationToken); - if (expiration.HasValue && expiration.Value < DateTimeOffset.UtcNow) - continue; + var key = row.Value.AuthorizationId ?? $"token:{row.Value.TokenId}"; + if (!groups.TryGetValue(key, out var bucket)) + { + bucket = new List(); + groups[key] = bucket; + } + bucket.Add(row.Value); + } - var appId = await tokenManager.GetApplicationIdAsync(token, cancellationToken); - string? appName = null; - if (appId is not null) + var sessions = new List(groups.Count); + foreach (var bucket in groups.Values) + { + // Prefer a refresh token as the anchor so the row reflects the longer- + // lived part of the session; fall back to the newest access token. + TokenRow? refreshAnchor = null; + foreach (var row in bucket) { - if (!appNameCache.TryGetValue(appId, out appName)) + if (row.Type == TokenTypeHints.RefreshToken) { - var app = await appManager.FindByIdAsync(appId, cancellationToken); - if (app is not null) - appName = await appManager.GetDisplayNameAsync(app, cancellationToken); - appNameCache[appId] = appName; + refreshAnchor = row; + break; } } + var anchor = + refreshAnchor + ?? bucket.OrderByDescending(t => t.CreationDate ?? DateTimeOffset.MinValue).First(); + + var appName = await ResolveAppNameAsync( + anchor.ApplicationId, + appNameCache, + cancellationToken + ); - var tokenId = - await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty; + var isCurrent = + ( + currentAuthorizationId is not null + && string.Equals( + anchor.AuthorizationId, + currentAuthorizationId, + StringComparison.Ordinal + ) + ) + || ( + !string.IsNullOrEmpty(currentTokenId) + && bucket.Any(t => + string.Equals(t.TokenId, currentTokenId, StringComparison.Ordinal) + ) + ); sessions.Add( new UserSessionDto { - TokenId = tokenId, - Type = type ?? string.Empty, + TokenId = anchor.TokenId, + Type = anchor.Type, ApplicationName = appName, - CreationDate = await tokenManager.GetCreationDateAsync( - token, - cancellationToken - ), - ExpirationDate = expiration, - IsCurrent = - !string.IsNullOrEmpty(currentTokenId) - && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal), + CreationDate = bucket.Min(t => t.CreationDate), + ExpirationDate = bucket.Max(t => t.ExpirationDate), + IsCurrent = isCurrent, } ); } @@ -74,22 +117,78 @@ public async Task> GetActiveSessionsForUserAsync( return sessions; } - public async Task TryRevokeSessionForUserAsync( + public async Task TryRevokeSessionForUserAsync( string tokenId, string userId, + string? currentTokenId, CancellationToken cancellationToken = default ) { - var token = await tokenManager.FindByIdAsync(tokenId, cancellationToken); - if (token is null) - return false; + var target = await tokenManager.FindByIdAsync(tokenId, cancellationToken); + if (target is null) + return RevokeSessionResult.NotFound; - var subject = await tokenManager.GetSubjectAsync(token, cancellationToken); + var subject = await tokenManager.GetSubjectAsync(target, cancellationToken); if (!string.Equals(subject, userId, StringComparison.Ordinal)) - return false; + return RevokeSessionResult.NotFound; + + var targetAuthorizationId = await tokenManager.GetAuthorizationIdAsync( + target, + cancellationToken + ); + var currentAuthorizationId = await GetAuthorizationIdForTokenAsync( + currentTokenId, + cancellationToken + ); + + // Self-revoke guard: same authorization, or same token id when no + // authorization is recorded. + if ( + targetAuthorizationId is not null + && currentAuthorizationId is not null + && string.Equals( + targetAuthorizationId, + currentAuthorizationId, + StringComparison.Ordinal + ) + ) + { + return RevokeSessionResult.BlockedCurrent; + } + if ( + targetAuthorizationId is null + && !string.IsNullOrEmpty(currentTokenId) + && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal) + ) + { + return RevokeSessionResult.BlockedCurrent; + } + + if (targetAuthorizationId is null) + { + // No authorization — just revoke this token. + await tokenManager.TryRevokeAsync(target, cancellationToken); + return RevokeSessionResult.Revoked; + } + + // Revoke every token in the same authorization for this user. Materialize + // first so we don't mutate the store mid-iteration. + var siblings = new List(); + await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) + { + var authId = await tokenManager.GetAuthorizationIdAsync(token, cancellationToken); + if (string.Equals(authId, targetAuthorizationId, StringComparison.Ordinal)) + { + siblings.Add(token); + } + } + + foreach (var token in siblings) + { + await tokenManager.TryRevokeAsync(token, cancellationToken); + } - await tokenManager.TryRevokeAsync(token, cancellationToken); - return true; + return RevokeSessionResult.Revoked; } public async Task RevokeSessionAsync( @@ -115,8 +214,11 @@ public async Task RevokeOtherSessionsForUserAsync( CancellationToken cancellationToken = default ) { - // Materialize valid tokens first; revoking inside the FindBySubjectAsync - // enumeration could mutate the underlying store mid-iteration. + var currentAuthorizationId = await GetAuthorizationIdForTokenAsync( + currentTokenId, + cancellationToken + ); + var tokensToRevoke = new List(); await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) @@ -125,7 +227,18 @@ public async Task RevokeOtherSessionsForUserAsync( if (status != Statuses.Valid) continue; - if (!string.IsNullOrEmpty(currentTokenId)) + // Same-authorization check first; falls through to token-id check for + // tokens that have no recorded authorization. + var authId = await tokenManager.GetAuthorizationIdAsync(token, cancellationToken); + if ( + currentAuthorizationId is not null + && authId is not null + && string.Equals(authId, currentAuthorizationId, StringComparison.Ordinal) + ) + { + continue; + } + if (authId is null && !string.IsNullOrEmpty(currentTokenId)) { var tokenId = await tokenManager.GetIdAsync(token, cancellationToken); if (string.Equals(tokenId, currentTokenId, StringComparison.Ordinal)) @@ -140,4 +253,98 @@ public async Task RevokeOtherSessionsForUserAsync( await tokenManager.TryRevokeAsync(token, cancellationToken); } } + + private async Task BuildDtoAsync( + object token, + Dictionary appNameCache, + CancellationToken cancellationToken + ) + { + var row = await ReadTokenAsync(token, cancellationToken); + if (row is null) + return null; + + var appName = await ResolveAppNameAsync(row.Value.ApplicationId, appNameCache, cancellationToken); + + return new UserSessionDto + { + TokenId = row.Value.TokenId, + Type = row.Value.Type, + ApplicationName = appName, + CreationDate = row.Value.CreationDate, + ExpirationDate = row.Value.ExpirationDate, + IsCurrent = false, + }; + } + + private async Task ReadTokenAsync(object token, CancellationToken cancellationToken) + { + var type = await tokenManager.GetTypeAsync(token, cancellationToken); + if (type is not (TokenTypeHints.AccessToken or TokenTypeHints.RefreshToken)) + return null; + + var status = await tokenManager.GetStatusAsync(token, cancellationToken); + if (status != Statuses.Valid) + return null; + + var expiration = await tokenManager.GetExpirationDateAsync(token, cancellationToken); + if (expiration.HasValue && expiration.Value < DateTimeOffset.UtcNow) + return null; + + var tokenId = await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty; + var creation = await tokenManager.GetCreationDateAsync(token, cancellationToken); + var appId = await tokenManager.GetApplicationIdAsync(token, cancellationToken); + var authorizationId = await tokenManager.GetAuthorizationIdAsync(token, cancellationToken); + + return new TokenRow( + tokenId, + type ?? string.Empty, + appId, + authorizationId, + creation, + expiration + ); + } + + private async Task ResolveAppNameAsync( + string? appId, + Dictionary cache, + CancellationToken cancellationToken + ) + { + if (appId is null) + return null; + + if (cache.TryGetValue(appId, out var cached)) + return cached; + + var app = await appManager.FindByIdAsync(appId, cancellationToken); + var name = app is null ? null : await appManager.GetDisplayNameAsync(app, cancellationToken); + cache[appId] = name; + return name; + } + + private async Task GetAuthorizationIdForTokenAsync( + string? tokenId, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrEmpty(tokenId)) + return null; + + var token = await tokenManager.FindByIdAsync(tokenId, cancellationToken); + if (token is null) + return null; + + return await tokenManager.GetAuthorizationIdAsync(token, cancellationToken); + } + + private readonly record struct TokenRow( + string TokenId, + string Type, + string? ApplicationId, + string? AuthorizationId, + DateTimeOffset? CreationDate, + DateTimeOffset? ExpirationDate + ); } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/components/ManageLayout.tsx b/modules/OpenIddict/src/SimpleModule.OpenIddict/components/ManageLayout.tsx new file mode 100644 index 00000000..51bc1e6c --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/components/ManageLayout.tsx @@ -0,0 +1,124 @@ +import { Card, CardContent, PageShell } from '@simplemodule/ui'; + +// Mirrors modules/Users/src/SimpleModule.Users/components/ManageLayout.tsx — the +// /Identity/Account/Manage/* nav lives in the Users module, but Vite library-mode +// bundles each module separately so OpenIddict can't import it. Keep these in +// sync when nav items change. Constitution rule SM0010 forbids OpenIddict → +// Users React imports. + +interface ManageLayoutProps { + activePage: string; + children: React.ReactNode; +} + +const navItems = [ + { + href: '/Identity/Account/Manage', + page: 'Index', + label: 'Profile', + icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z', + }, + { + href: '/Identity/Account/Manage/Email', + page: 'Email', + label: 'Email', + icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + }, + { + href: '/Identity/Account/Manage/ChangePassword', + page: 'ChangePassword', + label: 'Password', + icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', + }, + { + href: '/Identity/Account/Manage/TwoFactorAuthentication', + page: 'TwoFactorAuthentication', + label: 'Two-factor auth', + icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', + }, + { + href: '/Identity/Account/Manage/Passkeys', + page: 'Passkeys', + label: 'Passkeys', + icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', + }, + { + href: '/Identity/Account/Manage/PersonalData', + page: 'PersonalData', + label: 'Personal data', + icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4', + }, + { + href: '/Identity/Account/Manage/ActiveSessions', + page: 'ActiveSessions', + label: 'Active sessions', + icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + }, +]; + +function NavLink({ + href, + active, + icon, + children, +}: { + href: string; + active: boolean; + icon: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +export default function ManageLayout({ activePage, children }: ManageLayoutProps) { + return ( + +
+ +
+ + {children} + +
+
+
+ ); +} diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs index f62080ba..d84384fb 100644 --- a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs +++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/ActiveSessionsEndpointTests.cs @@ -4,9 +4,11 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; using SimpleModule.Tests.Shared.Fixtures; using SimpleModule.Testing; using SimpleModule.Users.Contracts; +using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpenIddict.Tests.Integration; @@ -50,16 +52,52 @@ private async Task SeedUserAsync(string idHint) return userId; } - private HttpClient CreateAuthenticatedNoRedirectClient(string userId) + private HttpClient CreateAuthenticatedNoRedirectClient(string userId, string? currentTokenId = null) { var client = _factory.CreateClient(NoRedirects()); - client.DefaultRequestHeaders.Add( - TestAuthDefaults.ClaimsHeader, - $"{ClaimTypes.NameIdentifier}={userId}" - ); + var claims = $"{ClaimTypes.NameIdentifier}={userId}"; + if (!string.IsNullOrEmpty(currentTokenId)) + { + // Matches the claim name OpenIddict's validation handler exposes on + // the principal (see ActiveSessionsHelpers.AccessTokenIdClaim). + claims += $";oi_tkn_id={currentTokenId}"; + } + client.DefaultRequestHeaders.Add(TestAuthDefaults.ClaimsHeader, claims); return client; } + private async Task<(string AuthorizationId, string AccessTokenId)> SeedAuthorizationWithTokensAsync( + string userId + ) + { + using var scope = _factory.Services.CreateScope(); + var authManager = scope.ServiceProvider.GetRequiredService(); + var auth = await authManager.CreateAsync( + new OpenIddictAuthorizationDescriptor + { + Subject = userId, + Status = Statuses.Valid, + Type = AuthorizationTypes.Permanent, + } + ); + var authId = (await authManager.GetIdAsync(auth))!; + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var token = await tokenManager.CreateAsync( + new OpenIddictTokenDescriptor + { + Subject = userId, + AuthorizationId = authId, + Type = TokenTypeHints.AccessToken, + Status = Statuses.Valid, + CreationDate = DateTimeOffset.UtcNow, + ExpirationDate = DateTimeOffset.UtcNow.AddDays(30), + } + ); + var tokenId = (await tokenManager.GetIdAsync(token))!; + return (authId, tokenId); + } + // ── GET page ─────────────────────────────────────────────────────── [Fact] @@ -124,6 +162,46 @@ public async Task Revoke_WhenSessionDoesNotBelongToCaller_Returns404() response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + [Fact] + public async Task Revoke_WhenTargetSharesCallersAuthorization_Returns400() + { + // Self-revoke must be rejected with 400 (not silently let through as a + // redirect, and not as 404), so the user can't kill their own session + // from under their own request — including via a sibling token id in + // the same authorization. + var userId = await SeedUserAsync("revoke-self-1"); + var (_, accessTokenId) = await SeedAuthorizationWithTokensAsync(userId); + using var client = CreateAuthenticatedNoRedirectClient(userId, currentTokenId: accessTokenId); + + var response = await client.PostAsync( + $"/Identity/Account/Manage/ActiveSessions/{accessTokenId}/revoke", + content: null + ); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Revoke_WhenTargetOwnedByCallerInDifferentAuthorization_RedirectsToListing() + { + 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); + + var response = await client.PostAsync( + $"/Identity/Account/Manage/ActiveSessions/{otherToken}/revoke", + content: null + ); + + response + .StatusCode.Should() + .BeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found); + response.Headers.Location?.ToString() + .Should() + .Contain("/Identity/Account/Manage/ActiveSessions"); + } + // ── POST revoke-others ───────────────────────────────────────────── [Fact] diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs new file mode 100644 index 00000000..abc16925 --- /dev/null +++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs @@ -0,0 +1,349 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.Tests.Shared.Fixtures; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace OpenIddict.Tests.Integration; + +/// +/// Behavioural tests for : token-pair +/// grouping by AuthorizationId, IsCurrent across siblings, and the revoke +/// guarantees the user-facing endpoints rely on. +/// +[Collection(TestCollections.Integration)] +public class OpenIddictSessionServiceTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public OpenIddictSessionServiceTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + // Force database initialization once so OpenIddict managers can resolve. + using (_factory.CreateAuthenticatedClient()) { } + } + + // ── Seeding ──────────────────────────────────────────────────────── + + private static async Task SeedAuthorizationAsync(string userId, IServiceProvider services) + { + var authManager = services.GetRequiredService(); + var auth = await authManager.CreateAsync( + new OpenIddictAuthorizationDescriptor + { + Subject = userId, + Status = Statuses.Valid, + Type = AuthorizationTypes.Permanent, + } + ); + return (await authManager.GetIdAsync(auth))!; + } + + private static async Task SeedTokenAsync( + string userId, + string authorizationId, + string type, + IServiceProvider services, + DateTimeOffset? creationDate = null + ) + { + var tokenManager = services.GetRequiredService(); + var descriptor = new OpenIddictTokenDescriptor + { + Subject = userId, + AuthorizationId = authorizationId, + Type = type, + Status = Statuses.Valid, + CreationDate = creationDate ?? DateTimeOffset.UtcNow, + ExpirationDate = DateTimeOffset.UtcNow.AddDays(30), + }; + var token = await tokenManager.CreateAsync(descriptor); + return (await tokenManager.GetIdAsync(token))!; + } + + private static string NewUserId([System.Runtime.CompilerServices.CallerMemberName] string caller = "") => + $"sess-svc-{caller}-{Guid.NewGuid():N}"; + + // ── Grouping ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetActiveSessions_GroupsAccessAndRefreshSharingAuthorization_IntoOneRow() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var authId = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + await SeedTokenAsync(userId, authId, TokenTypeHints.AccessToken, scope.ServiceProvider); + await SeedTokenAsync(userId, authId, TokenTypeHints.RefreshToken, scope.ServiceProvider); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var sessions = await contracts.GetActiveSessionsForUserAsync(userId, currentTokenId: null); + + sessions.Should().HaveCount(1); + // Refresh token is preferred as the anchor (longer-lived row). + sessions[0].Type.Should().Be(TokenTypeHints.RefreshToken); + } + + [Fact] + public async Task GetActiveSessions_MultipleAuthorizations_OneRowEach() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var auth1 = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var auth2 = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + await SeedTokenAsync(userId, auth1, TokenTypeHints.RefreshToken, scope.ServiceProvider); + await SeedTokenAsync(userId, auth2, TokenTypeHints.RefreshToken, scope.ServiceProvider); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var sessions = await contracts.GetActiveSessionsForUserAsync(userId, currentTokenId: null); + + sessions.Should().HaveCount(2); + } + + [Fact] + public async Task GetActiveSessions_IsCurrent_SetForBothSiblingsOfTheCallersToken() + { + // The principal carries the access token id; the rendered row uses the + // refresh token id (anchor). The IsCurrent flag must still come back true. + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var authId = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var accessId = await SeedTokenAsync( + userId, + authId, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + await SeedTokenAsync(userId, authId, TokenTypeHints.RefreshToken, scope.ServiceProvider); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var sessions = await contracts.GetActiveSessionsForUserAsync(userId, currentTokenId: accessId); + + sessions.Should().HaveCount(1); + sessions[0].IsCurrent.Should().BeTrue(); + } + + [Fact] + public async Task GetActiveSessions_FlatOverload_ReturnsOneRowPerToken() + { + // The admin-facing overload (no currentTokenId) intentionally keeps a + // row per token so each can be revoked individually. + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var authId = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + await SeedTokenAsync(userId, authId, TokenTypeHints.AccessToken, scope.ServiceProvider); + await SeedTokenAsync(userId, authId, TokenTypeHints.RefreshToken, scope.ServiceProvider); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var sessions = await contracts.GetActiveSessionsForUserAsync(userId); + + sessions.Should().HaveCount(2); + } + + // ── TryRevokeSessionForUserAsync ─────────────────────────────────── + + [Fact] + public async Task TryRevoke_SelfRevoke_ReturnsBlockedCurrent_AndDoesNotRevoke() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var authId = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var accessId = await SeedTokenAsync( + userId, + authId, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + var refreshId = await SeedTokenAsync( + userId, + authId, + TokenTypeHints.RefreshToken, + scope.ServiceProvider + ); + + var contracts = scope.ServiceProvider.GetRequiredService(); + + // Targeting either sibling must refuse when currentTokenId belongs to + // the same authorization. + var resultRefresh = await contracts.TryRevokeSessionForUserAsync( + refreshId, + userId, + currentTokenId: accessId + ); + var resultAccess = await contracts.TryRevokeSessionForUserAsync( + accessId, + userId, + currentTokenId: accessId + ); + + resultRefresh.Should().Be(RevokeSessionResult.BlockedCurrent); + resultAccess.Should().Be(RevokeSessionResult.BlockedCurrent); + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + var refreshToken = await tokenManager.FindByIdAsync(refreshId); + (await tokenManager.GetStatusAsync(refreshToken!)).Should().Be(Statuses.Valid); + } + + [Fact] + public async Task TryRevoke_OwnedTokenInDifferentAuthorization_RevokesAllSiblings() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var currentAuth = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var currentAccess = await SeedTokenAsync( + userId, + currentAuth, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + + var otherAuth = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var otherAccess = await SeedTokenAsync( + userId, + otherAuth, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + var otherRefresh = await SeedTokenAsync( + userId, + otherAuth, + TokenTypeHints.RefreshToken, + scope.ServiceProvider + ); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var result = await contracts.TryRevokeSessionForUserAsync( + otherRefresh, + userId, + currentTokenId: currentAccess + ); + + result.Should().Be(RevokeSessionResult.Revoked); + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + // Both siblings in the targeted authorization are revoked … + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(otherAccess))!)) + .Should() + .NotBe(Statuses.Valid); + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(otherRefresh))!)) + .Should() + .NotBe(Statuses.Valid); + // … and the caller's current session is untouched. + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(currentAccess))!)) + .Should() + .Be(Statuses.Valid); + } + + [Fact] + public async Task TryRevoke_TokenOwnedByDifferentUser_ReturnsNotFound() + { + var userId = NewUserId(); + var otherUserId = NewUserId() + "-other"; + using var scope = _factory.Services.CreateScope(); + var otherAuth = await SeedAuthorizationAsync(otherUserId, scope.ServiceProvider); + var otherToken = await SeedTokenAsync( + otherUserId, + otherAuth, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + + var contracts = scope.ServiceProvider.GetRequiredService(); + var result = await contracts.TryRevokeSessionForUserAsync( + otherToken, + userId, + currentTokenId: null + ); + + result.Should().Be(RevokeSessionResult.NotFound); + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(otherToken))!)) + .Should() + .Be(Statuses.Valid); + } + + [Fact] + public async Task TryRevoke_UnknownTokenId_ReturnsNotFound() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var contracts = scope.ServiceProvider.GetRequiredService(); + + var result = await contracts.TryRevokeSessionForUserAsync( + tokenId: "does-not-exist", + userId, + currentTokenId: null + ); + + result.Should().Be(RevokeSessionResult.NotFound); + } + + // ── RevokeOtherSessionsForUserAsync ──────────────────────────────── + + [Fact] + public async Task RevokeOthers_PreservesCurrentAuthorization_RevokesEverythingElse() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var currentAuth = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var currentAccess = await SeedTokenAsync( + userId, + currentAuth, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + var currentRefresh = await SeedTokenAsync( + userId, + currentAuth, + TokenTypeHints.RefreshToken, + scope.ServiceProvider + ); + + var otherAuth = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var otherAccess = await SeedTokenAsync( + userId, + otherAuth, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + + var contracts = scope.ServiceProvider.GetRequiredService(); + await contracts.RevokeOtherSessionsForUserAsync(userId, currentTokenId: currentAccess); + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + // Current authorization's tokens — including the refresh sibling — survive. + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(currentAccess))!)) + .Should() + .Be(Statuses.Valid); + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(currentRefresh))!)) + .Should() + .Be(Statuses.Valid); + // The other authorization is gone. + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(otherAccess))!)) + .Should() + .NotBe(Statuses.Valid); + } + + [Fact] + public async Task RevokeOthers_NullCurrentToken_RevokesEverythingForUser() + { + var userId = NewUserId(); + using var scope = _factory.Services.CreateScope(); + var authId = await SeedAuthorizationAsync(userId, scope.ServiceProvider); + var tokenId = await SeedTokenAsync( + userId, + authId, + TokenTypeHints.AccessToken, + scope.ServiceProvider + ); + + var contracts = scope.ServiceProvider.GetRequiredService(); + await contracts.RevokeOtherSessionsForUserAsync(userId, currentTokenId: null); + + var tokenManager = scope.ServiceProvider.GetRequiredService(); + (await tokenManager.GetStatusAsync((await tokenManager.FindByIdAsync(tokenId))!)) + .Should() + .NotBe(Statuses.Valid); + } +} From 4508d88d295f6426e5c1c6de5265d2cf2528630d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 21:49:00 +0200 Subject: [PATCH 3/3] chore(routes): format generated routes.ts with biome Wrap long arrow-function bodies in tenants.api, account.api and users.api so the file passes biome check. --- packages/SimpleModule.Client/src/routes.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 43db579c..d25e23f2 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -88,16 +88,19 @@ export const routes = { }, tenants: { api: { - deleteTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, + deleteTenantFeature: (id: string | number, flagName: string | number) => + `/api/tenants/${id}/features/${flagName}`, getTenantFeatures: (id: string | number) => `/api/tenants/${id}/features`, - setTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, + setTenantFeature: (id: string | number, flagName: string | number) => + `/api/tenants/${id}/features/${flagName}`, addHost: (id: string | number) => `/api/tenants/${id}/hosts`, changeStatus: (id: string | number) => `/api/tenants/${id}/status`, create: () => '/api/tenants' as const, delete: (id: string | number) => `/api/tenants/${id}`, getAll: () => '/api/tenants' as const, getById: (id: string | number) => `/api/tenants/${id}`, - removeHost: (id: string | number, hostId: string | number) => `/api/tenants/${id}/hosts/${hostId}`, + removeHost: (id: string | number, hostId: string | number) => + `/api/tenants/${id}/hosts/${hostId}`, update: (id: string | number) => `/api/tenants/${id}`, }, views: { @@ -194,7 +197,8 @@ export const routes = { userinfo: () => '/connect/userinfo' as const, activeSessions: () => '/Identity/Account/Manage/ActiveSessions' as const, revokeOtherSessions: () => '/Identity/Account/Manage/ActiveSessions/revoke-others' as const, - revokeSession: (tokenId: string | number) => `/Identity/Account/Manage/ActiveSessions/${tokenId}/revoke`, + revokeSession: (tokenId: string | number) => + `/Identity/Account/Manage/ActiveSessions/${tokenId}/revoke`, }, views: { clientsCreate: () => '/openiddict/clients/create' as const, @@ -205,7 +209,8 @@ export const routes = { admin: { api: { adminRoles: () => '/admin/roles' as const, - adminSessions: (id: string | number, tokenId: string | number) => `/admin/users/${id}/sessions/${tokenId}`, + adminSessions: (id: string | number, tokenId: string | number) => + `/admin/users/${id}/sessions/${tokenId}`, adminUsers: () => '/admin/users' as const, }, views: { @@ -219,4 +224,3 @@ export const routes = { }, }, } as const; -