From de6e8c14343950b64400bceee30607ed4948cf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 31 Oct 2026 22:43:28 +0300 Subject: [PATCH] feat(sessions): expose session count in chain summaries --- docs/content/client-sdk/session-management.md | 1 + .../docs/client-sdk/session-management.json | 4 +- .../Abstractions/Stores/ISessionStore.cs | 1 + .../Session/Dtos/SessionChainSummary.cs | 1 + .../Services/SessionApplicationService.cs | 13 ++-- .../Stores/EfCoreSessionStore.cs | 17 +++++ .../InMemorySessionStore.cs | 19 ++++++ .../Server/SessionApplicationServiceTests.cs | 62 +++++++++++++++++++ .../InMemorySessionStoreChainContractTests.cs | 3 +- 9 files changed, 112 insertions(+), 9 deletions(-) diff --git a/docs/content/client-sdk/session-management.md b/docs/content/client-sdk/session-management.md index 03ba446c..9e870348 100644 --- a/docs/content/client-sdk/session-management.md +++ b/docs/content/client-sdk/session-management.md @@ -101,6 +101,7 @@ Each summary provides information suitable for building a device management inte | `LastSeenAt` | `DateTimeOffset?` | Last recorded activity | | `RotationCount` | `int` | Recorded rotation count | | `TouchCount` | `int` | Recorded touch count | +| `SessionCount` | `int` | Total number of session records in the chain, including historical and revoked sessions | | `IsRevoked` | `bool` | Whether the chain is revoked | | `RevokedAt` | `DateTimeOffset?` | Revocation timestamp | | `ActiveSessionId` | `AuthSessionId?` | Associated active session identifier | diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json index 0026de63..bbc427b2 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json @@ -1,7 +1,7 @@ { "Slug": "client-sdk/session-management", "Title": "Session Management", - "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for inspecting and managing authentication sessions across devices and application instances.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Sessions\u003C/code\u003E, applications can list session chains, inspect authentication history, revoke individual chains, terminate sessions across devices, and perform administrative session management.\u003C/p\u003E\n\u003Cp\u003EThis guide demonstrates the available operations using the current \u003Ccode\u003EISessionClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-the-session-hierarchy\u0022\u003E1. Understanding the Session Hierarchy\u003C/h2\u003E\n\u003Cp\u003EIn session-managed authentication modes, UltimateAuth organizes authentication state into three levels:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ESession Root\n |\n \u002B-- Session Chain A (Browser)\n | |\n | \u002B-- Session A1\n | \u002B-- Session A2\n |\n \u002B-- Session Chain B (Mobile)\n |\n \u002B-- Session B1\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELevel\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERoot\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated authentication continuity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndividual authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese levels provide different boundaries for session management and revocation.\u003C/p\u003E\n\u003Cp\u003EA chain is associated with a device or client context, but it is not cryptographic proof of physical device identity.\u003C/p\u003E\n\u003Cp\u003EThe available hierarchy and enforcement behavior depend on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor architectural details, see \u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-session-client\u0022\u003E2. Accessing the Session Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESession operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Sessions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ESessions\u003C/code\u003E property exposes \u003Ccode\u003EISessionClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface separates operations for the current user from administrative operations targeting another user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-my-devices-and-session-chains\u0022\u003E3. List My Devices and Session Chains\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E to retrieve the current user\u0027s session chains.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine($\u0026quot;{chain.Browser} - {chain.OperatingSystem}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;SessionChainSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains a paginated collection of session chain summaries.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchainsummary\u0022\u003ESessionChainSummary\u003C/h3\u003E\n\u003Cp\u003EEach summary provides information suitable for building a device management interface.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnique chain identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice category\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOperatingSystem\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported operating system\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient platform\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBrowser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported browser\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EChain creation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELastSeenAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast recorded activity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotationCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded rotation count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded touch count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsRevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the chain is revoked\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevocation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActiveSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssociated active session identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsCurrentDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether this is the current device context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EDevice metadata is descriptive information, not a trusted physical device identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identify-the-current-device\u0022\u003EIdentify the Current Device\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsCurrentDevice\u003C/code\u003E to distinguish the current chain in your UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var current = page.Items.FirstOrDefault(x =\u0026gt; x.IsCurrentDevice);\n\n if (current is not null)\n {\n Console.WriteLine($\u0026quot;Current chain: {current.ChainId}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when displaying a device list with a \u003Cstrong\u003ECurrent device\u003C/strong\u003E indicator.\u003C/p\u003E\n\u003Cp\u003EThe property is determined by the server\u0027s current authentication context.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 10,\n SortBy = \u0026quot;CreatedAt\u0026quot;,\n Descending = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPageRequest\u003C/code\u003E supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size; zero uses the server pagination default\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sorting field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its pagination policy, including default and maximum page sizes.\u003C/p\u003E\n\u003Cp\u003ESupported sorting fields depend on the server endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar pageNumber = 1;\n\nvar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = pageNumber,\n PageSize = 10\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n // Render the chain.\n }\n\n if (page.HasNext)\n {\n // Enable the next-page action.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E includes \u003Ccode\u003ETotalCount\u003C/code\u003E, \u003Ccode\u003EPageNumber\u003C/code\u003E, \u003Ccode\u003EPageSize\u003C/code\u003E, and \u003Ccode\u003EHasNext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EYou can use these properties directly when building pagination controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-session-chain\u0022\u003E5. Inspect a Session Chain\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainDetailAsync\u003C/code\u003E to retrieve detailed information about a specific chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESessionChainId chainId = selectedChain.ChainId;\n\nvar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n Console.WriteLine($\u0026quot;State: {detail.State}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {detail.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Last seen: {detail.LastSeenAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;SessionChainDetail\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchaindetail\u0022\u003ESessionChainDetail\u003C/h3\u003E\n\u003Cp\u003EThe detail model includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EChain identity and device metadata.\u003C/li\u003E\n\u003Cli\u003ECreation and last-seen timestamps.\u003C/li\u003E\n\u003Cli\u003EChain state and revocation information.\u003C/li\u003E\n\u003Cli\u003ERotation and touch counters.\u003C/li\u003E\n\u003Cli\u003EActive session identifier.\u003C/li\u003E\n\u003Cli\u003ESession history.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-history\u0022\u003EInspect Session History\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine($\u0026quot;Session: {session.SessionId}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {session.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Expires: {session.ExpiresAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Revoked: {session.IsRevoked}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach session is represented by \u003Ccode\u003ESessionInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record SessionInfo(\n AuthSessionId SessionId,\n DateTimeOffset CreatedAt,\n DateTimeOffset ExpiresAt,\n bool IsRevoked);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to display individual authentication instances within a device-associated chain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-chain-states\u0022\u003E6. Understanding Chain States\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E defines three values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EState\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EActive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoked chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eswitch (chain.State)\n{\n case SessionChainState.Active:\n // Display as active.\n break;\n\n case SessionChainState.Passive:\n // Display as passive.\n break;\n\n case SessionChainState.Revoked:\n // Display as revoked.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA passive chain should not automatically be presented as a revoked chain.\u003C/p\u003E\n\u003Cp\u003EUse the returned state rather than inferring revocation from the absence of an active session.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-one-of-my-devices\u0022\u003E7. Revoke One of My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E to revoke a selected session chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess)\n{\n // Refresh the device list.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERevoking a chain invalidates its associated session continuity according to the server\u0027s revocation model.\u003C/p\u003E\n\u003Cp\u003EIt does not physically disable the device or prevent the user from authenticating again.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detect-revocation-of-the-current-chain\u0022\u003EDetect Revocation of the Current Chain\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERevokeResult\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeResult\n{\n public bool CurrentChain { get; init; }\n public bool RootRevoked { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can inspect these properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n if (revoke.CurrentChain)\n {\n // The current authentication chain was affected.\n }\n\n if (revoke.RootRevoked)\n {\n // The result reports root-level revocation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E state event.\u003C/p\u003E\n\u003Cp\u003EThis helps the application react when its own authentication context has been invalidated.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-other-devices\u0022\u003E8. Revoke All Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyOtherChainsAsync\u003C/code\u003E to revoke other session chains while retaining the current chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync();\n\nif (result.IsSuccess)\n{\n // Other device chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is suitable for a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action in account settings.\u003C/p\u003E\n\u003Cp\u003EThe server determines the current chain and applies the corresponding revocation scope.\u003C/p\u003E\n\u003Cp\u003EApplications should not attempt to identify the current device by comparing browser names, operating systems, or other descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-my-devices\u0022\u003E9. Revoke All My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E to revoke every session chain associated with the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllMyChainsAsync();\n\nif (result.IsSuccess)\n{\n // All applicable chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis includes the current device-associated chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EA typical use case is an account security page offering \u003Cstrong\u003ELog out everywhere\u003C/strong\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ERevoking server-managed sessions prevents their continued use where the authentication mode enforces that state.\u003C/p\u003E\n\u003Cp\u003EHowever, previously issued JWT access tokens may remain valid until expiration if the receiving resource server validates them locally without consulting current revocation state.\u003C/p\u003E\n\u003Cp\u003EDo not assume that chain revocation automatically invalidates every access token in every authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-session-revocation\u0022\u003E10. Logout Versus Session Revocation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes authentication logout operations through \u003Ccode\u003EFlows\u003C/code\u003E and session management operations through \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBoth may affect authentication state, but they serve different application responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003ERecommended API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ENormal logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout a selected device\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyDeviceAsync(request)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout other devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyOtherDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout all devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAllMyDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInspect session chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.GetMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke a selected chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyChainAsync(chainId)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke other chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke all chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022which-api-should-you-use\u0022\u003EWhich API Should You Use?\u003C/h3\u003E\n\u003Cp\u003EFor ordinary authentication actions, prefer \u003Ccode\u003EFlows\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a session management interface that lists and controls chains, use \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe APIs expose related capabilities through different client surfaces.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EFlows\u003C/code\u003E API emphasizes authentication workflows, while \u003Ccode\u003ESessions\u003C/code\u003E exposes the session management model more directly.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-device-through-flows\u0022\u003ELogout a Device Through Flows\u003C/h3\u003E\n\u003Cp\u003EThe device logout method requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyDeviceAsync(\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPass a \u003Ccode\u003ESessionChainId\u003C/code\u003E, not an individual \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor complete logout examples, see \u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-device-management-page\u0022\u003E11. Build a Device Management Page\u003C/h2\u003E\n\u003Cp\u003EA common application requirement is to show all devices associated with the current account and allow the user to revoke them.\u003C/p\u003E\n\u003Cp\u003EThe following example uses the session client to build a simple Blazor device list.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Core.Domain\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Devices\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading devices...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var chain in _chains)\n {\n \u0026lt;div @key=\u0026quot;chain.ChainId\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@(chain.Browser ?? \u0026quot;Unknown browser\u0026quot;)\u0026lt;/strong\u0026gt;\n \u0026lt;p\u0026gt;@(chain.OperatingSystem ?? \u0026quot;Unknown OS\u0026quot;)\u0026lt;/p\u0026gt;\n \u0026lt;p\u0026gt;State: @chain.State\u0026lt;/p\u0026gt;\n\n @if (chain.IsCurrentDevice)\n {\n \u0026lt;span\u0026gt;Current device\u0026lt;/span\u0026gt;\n }\n\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; RevokeAsync(chain.ChainId)\u0026quot;\u0026gt;Revoke\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n@code {\n private IReadOnlyList\u0026lt;SessionChainSummary\u0026gt; _chains = [];\n private bool _loading = true;\n private string? _error;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Sessions.GetMyChainsAsync(new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _chains = page.Items;\n }\n else\n {\n _error = \u0026quot;Unable to load devices.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The session service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task RevokeAsync(SessionChainId chainId)\n {\n var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _error = \u0026quot;Unable to revoke the selected device.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core integration.\u003C/p\u003E\n\u003Cp\u003EA production UI should additionally provide confirmation before revocation, disable repeated submissions while an operation is in progress, and handle the possibility that the current device is being revoked.\u003C/p\u003E\n\u003Cp\u003EThe example displays the first page. Use the pagination API to expose additional chains.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-session-management\u0022\u003E12. Administrative Session Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative operations allow authorized actors to inspect and manage another user\u0027s session state.\u003C/p\u003E\n\u003Cp\u003EThese methods require appropriate server-side permissions.\u003C/p\u003E\n\u003Cp\u003EThe presence of an administrative SDK method does not grant administrative access.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-a-users-devices\u0022\u003EList a User\u0027s Devices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine(chain.ChainId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-users-chain\u0022\u003EInspect a User\u0027s Chain\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserChainDetailAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainDetailAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine(session.SessionId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis provides the same \u003Ccode\u003ESessionChainDetail\u003C/code\u003E model used for self-service chain inspection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-an-individual-session\u0022\u003ERevoke an Individual Session\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeUserSessionAsync\u003C/code\u003E when you need to revoke one authentication instance rather than its entire chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserSessionAsync(userKey, sessionId);\n\nif (result.IsSuccess)\n{\n // The session revocation operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method requires an \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is different from chain revocation.\u003C/p\u003E\n\u003Cp\u003EA session-level operation targets a single authentication instance; a chain-level operation targets the associated session continuity boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-device-chain\u0022\u003ERevoke a User\u0027s Device Chain\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserChainAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n // Inspect revoke.CurrentChain or revoke.RootRevoked if needed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-user-chains\u0022\u003ERevoke All User Chains\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(userKey);\n\nif (result.IsSuccess)\n{\n // All applicable user chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for administrative account security operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-the-users-session-root\u0022\u003ERevoke the User\u0027s Session Root\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also exposes root-level revocation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserRootAsync(userKey);\n\nif (result.IsSuccess)\n{\n // Root-level revocation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERoot revocation is a stronger account-level security operation than revoking one session or one chain.\u003C/p\u003E\n\u003Cp\u003EIt is intended for situations where the user\u0027s broader session authority must be invalidated.\u003C/p\u003E\n\u003Cp\u003EExamples include account compromise response or other security-critical administrative actions.\u003C/p\u003E\n\u003Cp\u003EDo not use root revocation as a routine substitute for ordinary logout.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-revocation-scope\u0022\u003E13. Choosing the Correct Revocation Scope\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides multiple revocation levels because different security events require different responses.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScope\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003Cth\u003EIntended effect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECurrent logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd current authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIndividual session\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserSessionAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke one authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOne chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyChainAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOther chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERetain current chain, revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAll chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all current user\u0027s chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser root\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserRootAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInvalidate account-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse the narrowest scope that satisfies the security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example, removing one unrecognized device from an account should not automatically require revoking the entire user\u0027s session root.\u003C/p\u003E\n\u003Cp\u003EConversely, a confirmed account-wide compromise may justify broader revocation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-session-operation-results\u0022\u003E14. Handling Session Operation Results\u003C/h2\u003E\n\u003Cp\u003ESession client methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA typical result-handling pattern is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n // Use the returned data.\n}\nelse if (result.Http.IsUnauthorized)\n{\n // Authentication is required.\n}\nelse if (result.Http.IsForbidden)\n{\n // The operation is not authorized.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAvailable response information includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EProblem\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EErrorText\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECorrelationId\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETraceId\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe \u003Ccode\u003EHttp\u003C/code\u003E helper exposes status checks such as \u003Ccode\u003EIsBadRequest\u003C/code\u003E, \u003Ccode\u003EIsUnauthorized\u003C/code\u003E, \u003Ccode\u003EIsForbidden\u003C/code\u003E, and \u003Ccode\u003EIsConflict\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-authorization-failures\u0022\u003EAdministrative Authorization Failures\u003C/h3\u003E\n\u003Cp\u003EAn administrative session operation may return \u003Ccode\u003E403 Forbidden\u003C/code\u003E when the actor lacks the required permission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.Http.IsForbidden)\n{\n // The current actor cannot inspect this user\u0027s sessions.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not attempt to work around authorization failures by switching to a different endpoint.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing the operation\u0027s access policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-state-events\u0022\u003E15. Session State Events\u003C/h2\u003E\n\u003Cp\u003EThe session client publishes state events for certain successful revocation operations.\u003C/p\u003E\n\u003Cp\u003EThe current implementation publishes \u003Ccode\u003ESessionRevoked\u003C/code\u003E when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and the result identifies the current chain.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E succeeds.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther session management methods do not necessarily publish the same event.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that every successful administrative revocation produces a local state event.\u003C/p\u003E\n\u003Cp\u003EState events help the client update its authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EThey do not replace authoritative server-side session validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-operational-considerations\u0022\u003E16. Security and Operational Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-metadata-is-descriptive\u0022\u003EDevice Metadata Is Descriptive\u003C/h3\u003E\n\u003Cp\u003EBrowser, operating system, platform, and device type are useful for displaying session information.\u003C/p\u003E\n\u003Cp\u003EThey are not reliable proof of physical device ownership.\u003C/p\u003E\n\u003Cp\u003EA malicious or modified client may be able to influence some reported metadata.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chains-are-not-permanent-device-identities\u0022\u003ESession Chains Are Not Permanent Device Identities\u003C/h3\u003E\n\u003Cp\u003ERevoking a chain invalidates its authentication continuity.\u003C/p\u003E\n\u003Cp\u003EIt does not permanently ban the physical device.\u003C/p\u003E\n\u003Cp\u003EA later successful login may establish new authentication state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revocation-is-server-controlled\u0022\u003ERevocation Is Server-Controlled\u003C/h3\u003E\n\u003Cp\u003EClient-side removal of a device card does not revoke the underlying session.\u003C/p\u003E\n\u003Cp\u003EAlways use the appropriate session or logout operation and inspect its result.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-matter\u0022\u003EAuthentication Modes Matter\u003C/h3\u003E\n\u003Cp\u003EThe Root \u2192 Chain \u2192 Session hierarchy is relevant to server-managed session modes.\u003C/p\u003E\n\u003Cp\u003EDo not assume identical session management semantics in PureJwt or other modes that do not enforce the same hierarchy during ordinary request validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative session revocation can interrupt another user\u0027s authentication.\u003C/p\u003E\n\u003Cp\u003EThese operations should be protected by appropriate permissions and, where required, stronger authentication or administrative confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication state validation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E \u2014 Session roots, chains, lifecycle, and expiration.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Device-associated authentication contexts.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Revocation and session security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003EUltimateAuth provides a dedicated client API for inspecting and managing authentication sessions across devices and application instances.\u003C/p\u003E\n\u003Cp\u003EThrough \u003Ccode\u003EUAuthClient.Sessions\u003C/code\u003E, applications can list session chains, inspect authentication history, revoke individual chains, terminate sessions across devices, and perform administrative session management.\u003C/p\u003E\n\u003Cp\u003EThis guide demonstrates the available operations using the current \u003Ccode\u003EISessionClient\u003C/code\u003E contract.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-the-session-hierarchy\u0022\u003E1. Understanding the Session Hierarchy\u003C/h2\u003E\n\u003Cp\u003EIn session-managed authentication modes, UltimateAuth organizes authentication state into three levels:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ESession Root\n |\n \u002B-- Session Chain A (Browser)\n | |\n | \u002B-- Session A1\n | \u002B-- Session A2\n |\n \u002B-- Session Chain B (Mobile)\n |\n \u002B-- Session B1\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ELevel\u003C/th\u003E\n\u003Cth\u003EResponsibility\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ERoot\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EAccount-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003EChain\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice-associated authentication continuity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Cstrong\u003ESession\u003C/strong\u003E\u003C/td\u003E\n\u003Ctd\u003EIndividual authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese levels provide different boundaries for session management and revocation.\u003C/p\u003E\n\u003Cp\u003EA chain is associated with a device or client context, but it is not cryptographic proof of physical device identity.\u003C/p\u003E\n\u003Cp\u003EThe available hierarchy and enforcement behavior depend on the authentication mode.\u003C/p\u003E\n\u003Cp\u003EFor architectural details, see \u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022accessing-the-session-client\u0022\u003E2. Accessing the Session Client\u003C/h2\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into your Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ESession operations are available through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthClient.Sessions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003ESessions\u003C/code\u003E property exposes \u003Ccode\u003EISessionClient\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe interface separates operations for the current user from administrative operations targeting another user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-my-devices-and-session-chains\u0022\u003E3. List My Devices and Session Chains\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E to retrieve the current user\u0027s session chains.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine($\u0026quot;{chain.Browser} - {chain.OperatingSystem}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;PagedResult\u0026lt;SessionChainSummary\u0026gt;\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe response contains a paginated collection of session chain summaries.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchainsummary\u0022\u003ESessionChainSummary\u003C/h3\u003E\n\u003Cp\u003EEach summary provides information suitable for building a device management interface.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUnique chain identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDevice category\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOperatingSystem\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported operating system\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EClient platform\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBrowser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Estring?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReported browser\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECreatedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EChain creation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELastSeenAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELast recorded activity\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotationCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded rotation count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERecorded touch count\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionCount\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Eint\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETotal number of session records in the chain, including historical and revoked sessions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsRevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether the chain is revoked\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokedAt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EDateTimeOffset?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevocation timestamp\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActiveSessionId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthSessionId?\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAssociated active session identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIsCurrentDevice\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ebool\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EWhether this is the current device context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECurrent chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EDevice metadata is descriptive information, not a trusted physical device identifier.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identify-the-current-device\u0022\u003EIdentify the Current Device\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIsCurrentDevice\u003C/code\u003E to distinguish the current chain in your UI.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n var current = page.Items.FirstOrDefault(x =\u0026gt; x.IsCurrentDevice);\n\n if (current is not null)\n {\n Console.WriteLine($\u0026quot;Current chain: {current.ChainId}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful when displaying a device list with a \u003Cstrong\u003ECurrent device\u003C/strong\u003E indicator.\u003C/p\u003E\n\u003Cp\u003EThe property is determined by the server\u0027s current authentication context.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination\u0022\u003E4. Pagination\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EGetMyChainsAsync\u003C/code\u003E accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 10,\n SortBy = \u0026quot;CreatedAt\u0026quot;,\n Descending = true\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPageRequest\u003C/code\u003E supports:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageNumber\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERequested page size; zero uses the server pagination default\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESortBy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional sorting field\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDescending\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescending sort order\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe server applies its pagination policy, including default and maximum page sizes.\u003C/p\u003E\n\u003Cp\u003ESupported sorting fields depend on the server endpoint.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigating-pages\u0022\u003ENavigating Pages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar pageNumber = 1;\n\nvar result = await UAuthClient.Sessions.GetMyChainsAsync(\n new PageRequest\n {\n PageNumber = pageNumber,\n PageSize = 10\n });\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n // Render the chain.\n }\n\n if (page.HasNext)\n {\n // Enable the next-page action.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EPagedResult\u0026lt;T\u0026gt;\u003C/code\u003E includes \u003Ccode\u003ETotalCount\u003C/code\u003E, \u003Ccode\u003EPageNumber\u003C/code\u003E, \u003Ccode\u003EPageSize\u003C/code\u003E, and \u003Ccode\u003EHasNext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EYou can use these properties directly when building pagination controls.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-session-chain\u0022\u003E5. Inspect a Session Chain\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetMyChainDetailAsync\u003C/code\u003E to retrieve detailed information about a specific chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESessionChainId chainId = selectedChain.ChainId;\n\nvar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n Console.WriteLine($\u0026quot;State: {detail.State}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {detail.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Last seen: {detail.LastSeenAt}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;SessionChainDetail\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022sessionchaindetail\u0022\u003ESessionChainDetail\u003C/h3\u003E\n\u003Cp\u003EThe detail model includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EChain identity and device metadata.\u003C/li\u003E\n\u003Cli\u003ECreation and last-seen timestamps.\u003C/li\u003E\n\u003Cli\u003EChain state and revocation information.\u003C/li\u003E\n\u003Cli\u003ERotation and touch counters.\u003C/li\u003E\n\u003Cli\u003EActive session identifier.\u003C/li\u003E\n\u003Cli\u003ESession history.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-session-history\u0022\u003EInspect Session History\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine($\u0026quot;Session: {session.SessionId}\u0026quot;);\n Console.WriteLine($\u0026quot;Created: {session.CreatedAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Expires: {session.ExpiresAt}\u0026quot;);\n Console.WriteLine($\u0026quot;Revoked: {session.IsRevoked}\u0026quot;);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach session is represented by \u003Ccode\u003ESessionInfo\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record SessionInfo(\n AuthSessionId SessionId,\n DateTimeOffset CreatedAt,\n DateTimeOffset ExpiresAt,\n bool IsRevoked);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to display individual authentication instances within a device-associated chain.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022understanding-chain-states\u0022\u003E6. Understanding Chain States\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionChainState\u003C/code\u003E defines three values:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EState\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EActive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EActive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPassive\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPassive chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevoked\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoked chain state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eswitch (chain.State)\n{\n case SessionChainState.Active:\n // Display as active.\n break;\n\n case SessionChainState.Passive:\n // Display as passive.\n break;\n\n case SessionChainState.Revoked:\n // Display as revoked.\n break;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA passive chain should not automatically be presented as a revoked chain.\u003C/p\u003E\n\u003Cp\u003EUse the returned state rather than inferring revocation from the absence of an active session.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-one-of-my-devices\u0022\u003E7. Revoke One of My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E to revoke a selected session chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess)\n{\n // Refresh the device list.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERevoking a chain invalidates its associated session continuity according to the server\u0027s revocation model.\u003C/p\u003E\n\u003Cp\u003EIt does not physically disable the device or prevent the user from authenticating again.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022detect-revocation-of-the-current-chain\u0022\u003EDetect Revocation of the Current Chain\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003ERevokeResult\u003C/code\u003E contains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic sealed record RevokeResult\n{\n public bool CurrentChain { get; init; }\n public bool RootRevoked { get; init; }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EYou can inspect these properties:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n if (revoke.CurrentChain)\n {\n // The current authentication chain was affected.\n }\n\n if (revoke.RootRevoked)\n {\n // The result reports root-level revocation.\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen \u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and \u003Ccode\u003ECurrentChain\u003C/code\u003E is true, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E state event.\u003C/p\u003E\n\u003Cp\u003EThis helps the application react when its own authentication context has been invalidated.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-other-devices\u0022\u003E8. Revoke All Other Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeMyOtherChainsAsync\u003C/code\u003E to revoke other session chains while retaining the current chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync();\n\nif (result.IsSuccess)\n{\n // Other device chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is suitable for a \u003Cstrong\u003ELog out other devices\u003C/strong\u003E action in account settings.\u003C/p\u003E\n\u003Cp\u003EThe server determines the current chain and applies the corresponding revocation scope.\u003C/p\u003E\n\u003Cp\u003EApplications should not attempt to identify the current device by comparing browser names, operating systems, or other descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-my-devices\u0022\u003E9. Revoke All My Devices\u003C/h2\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E to revoke every session chain associated with the current user.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllMyChainsAsync();\n\nif (result.IsSuccess)\n{\n // All applicable chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis includes the current device-associated chain.\u003C/p\u003E\n\u003Cp\u003EWhen the operation succeeds, the SDK publishes a \u003Ccode\u003ESessionRevoked\u003C/code\u003E event.\u003C/p\u003E\n\u003Cp\u003EA typical use case is an account security page offering \u003Cstrong\u003ELog out everywhere\u003C/strong\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ERevoking server-managed sessions prevents their continued use where the authentication mode enforces that state.\u003C/p\u003E\n\u003Cp\u003EHowever, previously issued JWT access tokens may remain valid until expiration if the receiving resource server validates them locally without consulting current revocation state.\u003C/p\u003E\n\u003Cp\u003EDo not assume that chain revocation automatically invalidates every access token in every authentication mode.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-versus-session-revocation\u0022\u003E10. Logout Versus Session Revocation\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth exposes authentication logout operations through \u003Ccode\u003EFlows\u003C/code\u003E and session management operations through \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EBoth may affect authentication state, but they serve different application responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003ERecommended API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ENormal logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout a selected device\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyDeviceAsync(request)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout other devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutMyOtherDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout all devices\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Flows.LogoutAllMyDevicesAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInspect session chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.GetMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke a selected chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyChainAsync(chainId)\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke other chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERevoke all chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient.Sessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022which-api-should-you-use\u0022\u003EWhich API Should You Use?\u003C/h3\u003E\n\u003Cp\u003EFor ordinary authentication actions, prefer \u003Ccode\u003EFlows\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eawait UAuthClient.Flows.LogoutAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor a session management interface that lists and controls chains, use \u003Ccode\u003ESessions\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe APIs expose related capabilities through different client surfaces.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003EFlows\u003C/code\u003E API emphasizes authentication workflows, while \u003Ccode\u003ESessions\u003C/code\u003E exposes the session management model more directly.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout-a-device-through-flows\u0022\u003ELogout a Device Through Flows\u003C/h3\u003E\n\u003Cp\u003EThe device logout method requires \u003Ccode\u003ELogoutDeviceRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Flows.LogoutMyDeviceAsync(\n new LogoutDeviceRequest\n {\n ChainId = chainId\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EPass a \u003Ccode\u003ESessionChainId\u003C/code\u003E, not an individual \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor complete logout examples, see \u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022build-a-device-management-page\u0022\u003E11. Build a Device Management Page\u003C/h2\u003E\n\u003Cp\u003EA common application requirement is to show all devices associated with the current account and allow the user to revoke them.\u003C/p\u003E\n\u003Cp\u003EThe following example uses the session client to build a simple Blazor device list.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@using CodeBeam.UltimateAuth.Core.Contracts\n@using CodeBeam.UltimateAuth.Core.Domain\n@inject IUAuthClient UAuthClient\n\n\u0026lt;h3\u0026gt;My Devices\u0026lt;/h3\u0026gt;\n\n@if (_loading)\n{\n \u0026lt;p\u0026gt;Loading devices...\u0026lt;/p\u0026gt;\n}\nelse if (_error is not null)\n{\n \u0026lt;p role=\u0026quot;alert\u0026quot;\u0026gt;@_error\u0026lt;/p\u0026gt;\n}\nelse\n{\n @foreach (var chain in _chains)\n {\n \u0026lt;div @key=\u0026quot;chain.ChainId\u0026quot;\u0026gt;\n \u0026lt;strong\u0026gt;@(chain.Browser ?? \u0026quot;Unknown browser\u0026quot;)\u0026lt;/strong\u0026gt;\n \u0026lt;p\u0026gt;@(chain.OperatingSystem ?? \u0026quot;Unknown OS\u0026quot;)\u0026lt;/p\u0026gt;\n \u0026lt;p\u0026gt;State: @chain.State\u0026lt;/p\u0026gt;\n\n @if (chain.IsCurrentDevice)\n {\n \u0026lt;span\u0026gt;Current device\u0026lt;/span\u0026gt;\n }\n\n \u0026lt;button type=\u0026quot;button\u0026quot; @onclick=\u0026quot;() =\u0026gt; RevokeAsync(chain.ChainId)\u0026quot;\u0026gt;Revoke\u0026lt;/button\u0026gt;\n \u0026lt;/div\u0026gt;\n }\n}\n\n@code {\n private IReadOnlyList\u0026lt;SessionChainSummary\u0026gt; _chains = [];\n private bool _loading = true;\n private string? _error;\n\n protected override async Task OnInitializedAsync()\n {\n await LoadAsync();\n }\n\n private async Task LoadAsync()\n {\n _loading = true;\n _error = null;\n\n try\n {\n var result = await UAuthClient.Sessions.GetMyChainsAsync(new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\n if (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n {\n _chains = page.Items;\n }\n else\n {\n _error = \u0026quot;Unable to load devices.\u0026quot;;\n }\n }\n catch (Exception)\n {\n _error = \u0026quot;The session service is unavailable.\u0026quot;;\n }\n finally\n {\n _loading = false;\n }\n }\n\n private async Task RevokeAsync(SessionChainId chainId)\n {\n var result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId);\n\n if (result.IsSuccess)\n {\n await LoadAsync();\n }\n else\n {\n _error = \u0026quot;Unable to revoke the selected device.\u0026quot;;\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example demonstrates the core integration.\u003C/p\u003E\n\u003Cp\u003EA production UI should additionally provide confirmation before revocation, disable repeated submissions while an operation is in progress, and handle the possibility that the current device is being revoked.\u003C/p\u003E\n\u003Cp\u003EThe example displays the first page. Use the pagination API to expose additional chains.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-session-management\u0022\u003E12. Administrative Session Management\u003C/h2\u003E\n\u003Cp\u003EAdministrative operations allow authorized actors to inspect and manage another user\u0027s session state.\u003C/p\u003E\n\u003Cp\u003EThese methods require appropriate server-side permissions.\u003C/p\u003E\n\u003Cp\u003EThe presence of an administrative SDK method does not grant administrative access.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022list-a-users-devices\u0022\u003EList a User\u0027s Devices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n foreach (var chain in page.Items)\n {\n Console.WriteLine(chain.ChainId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method accepts an optional \u003Ccode\u003EPageRequest\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey,\n new PageRequest\n {\n PageNumber = 1,\n PageSize = 20\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022inspect-a-users-chain\u0022\u003EInspect a User\u0027s Chain\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EGetUserChainDetailAsync\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainDetailAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } detail)\n{\n foreach (var session in detail.Sessions)\n {\n Console.WriteLine(session.SessionId);\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis provides the same \u003Ccode\u003ESessionChainDetail\u003C/code\u003E model used for self-service chain inspection.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-an-individual-session\u0022\u003ERevoke an Individual Session\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003ERevokeUserSessionAsync\u003C/code\u003E when you need to revoke one authentication instance rather than its entire chain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserSessionAsync(userKey, sessionId);\n\nif (result.IsSuccess)\n{\n // The session revocation operation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method requires an \u003Ccode\u003EAuthSessionId\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis is different from chain revocation.\u003C/p\u003E\n\u003Cp\u003EA session-level operation targets a single authentication instance; a chain-level operation targets the associated session continuity boundary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-a-users-device-chain\u0022\u003ERevoke a User\u0027s Device Chain\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserChainAsync(userKey, chainId);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } revoke)\n{\n // Inspect revoke.CurrentChain or revoke.RootRevoked if needed.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe method returns \u003Ccode\u003EUAuthResult\u0026lt;RevokeResult\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-all-user-chains\u0022\u003ERevoke All User Chains\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(userKey);\n\nif (result.IsSuccess)\n{\n // All applicable user chains were revoked.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is useful for administrative account security operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revoke-the-users-session-root\u0022\u003ERevoke the User\u0027s Session Root\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also exposes root-level revocation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.RevokeUserRootAsync(userKey);\n\nif (result.IsSuccess)\n{\n // Root-level revocation succeeded.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERoot revocation is a stronger account-level security operation than revoking one session or one chain.\u003C/p\u003E\n\u003Cp\u003EIt is intended for situations where the user\u0027s broader session authority must be invalidated.\u003C/p\u003E\n\u003Cp\u003EExamples include account compromise response or other security-critical administrative actions.\u003C/p\u003E\n\u003Cp\u003EDo not use root revocation as a routine substitute for ordinary logout.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-the-correct-revocation-scope\u0022\u003E13. Choosing the Correct Revocation Scope\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides multiple revocation levels because different security events require different responses.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EScope\u003C/th\u003E\n\u003Cth\u003EAPI\u003C/th\u003E\n\u003Cth\u003EIntended effect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECurrent logout\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlows.LogoutAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnd current authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIndividual session\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserSessionAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke one authentication instance\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOne chain\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyChainAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke a device-associated chain\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EOther chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeMyOtherChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERetain current chain, revoke others\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAll chains\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeAllMyChainsAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all current user\u0027s chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUser root\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessions.RevokeUserRootAsync()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInvalidate account-level session authority\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse the narrowest scope that satisfies the security requirement.\u003C/p\u003E\n\u003Cp\u003EFor example, removing one unrecognized device from an account should not automatically require revoking the entire user\u0027s session root.\u003C/p\u003E\n\u003Cp\u003EConversely, a confirmed account-wide compromise may justify broader revocation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022handling-session-operation-results\u0022\u003E14. Handling Session Operation Results\u003C/h2\u003E\n\u003Cp\u003ESession client methods return \u003Ccode\u003EUAuthResult\u003C/code\u003E or \u003Ccode\u003EUAuthResult\u0026lt;T\u0026gt;\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA typical result-handling pattern is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetMyChainsAsync();\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is { } page)\n{\n // Use the returned data.\n}\nelse if (result.Http.IsUnauthorized)\n{\n // Authentication is required.\n}\nelse if (result.Http.IsForbidden)\n{\n // The operation is not authorized.\n}\nelse\n{\n // Handle another failure.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAvailable response information includes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EIsSuccess\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EStatus\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EProblem\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EErrorText\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECorrelationId\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETraceId\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe \u003Ccode\u003EHttp\u003C/code\u003E helper exposes status checks such as \u003Ccode\u003EIsBadRequest\u003C/code\u003E, \u003Ccode\u003EIsUnauthorized\u003C/code\u003E, \u003Ccode\u003EIsForbidden\u003C/code\u003E, and \u003Ccode\u003EIsConflict\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022administrative-authorization-failures\u0022\u003EAdministrative Authorization Failures\u003C/h3\u003E\n\u003Cp\u003EAn administrative session operation may return \u003Ccode\u003E403 Forbidden\u003C/code\u003E when the actor lacks the required permission.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Sessions.GetUserChainsAsync(userKey);\n\nif (result.Http.IsForbidden)\n{\n // The current actor cannot inspect this user\u0027s sessions.\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDo not attempt to work around authorization failures by switching to a different endpoint.\u003C/p\u003E\n\u003Cp\u003EThe server is responsible for enforcing the operation\u0027s access policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-state-events\u0022\u003E15. Session State Events\u003C/h2\u003E\n\u003Cp\u003EThe session client publishes state events for certain successful revocation operations.\u003C/p\u003E\n\u003Cp\u003EThe current implementation publishes \u003Ccode\u003ESessionRevoked\u003C/code\u003E when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeMyChainAsync\u003C/code\u003E succeeds and the result identifies the current chain.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERevokeAllMyChainsAsync\u003C/code\u003E succeeds.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOther session management methods do not necessarily publish the same event.\u003C/p\u003E\n\u003Cp\u003EApplications should not assume that every successful administrative revocation produces a local state event.\u003C/p\u003E\n\u003Cp\u003EState events help the client update its authentication-related UI.\u003C/p\u003E\n\u003Cp\u003EThey do not replace authoritative server-side session validation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-and-operational-considerations\u0022\u003E16. Security and Operational Considerations\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022device-metadata-is-descriptive\u0022\u003EDevice Metadata Is Descriptive\u003C/h3\u003E\n\u003Cp\u003EBrowser, operating system, platform, and device type are useful for displaying session information.\u003C/p\u003E\n\u003Cp\u003EThey are not reliable proof of physical device ownership.\u003C/p\u003E\n\u003Cp\u003EA malicious or modified client may be able to influence some reported metadata.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chains-are-not-permanent-device-identities\u0022\u003ESession Chains Are Not Permanent Device Identities\u003C/h3\u003E\n\u003Cp\u003ERevoking a chain invalidates its authentication continuity.\u003C/p\u003E\n\u003Cp\u003EIt does not permanently ban the physical device.\u003C/p\u003E\n\u003Cp\u003EA later successful login may establish new authentication state.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022revocation-is-server-controlled\u0022\u003ERevocation Is Server-Controlled\u003C/h3\u003E\n\u003Cp\u003EClient-side removal of a device card does not revoke the underlying session.\u003C/p\u003E\n\u003Cp\u003EAlways use the appropriate session or logout operation and inspect its result.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-matter\u0022\u003EAuthentication Modes Matter\u003C/h3\u003E\n\u003Cp\u003EThe Root \u2192 Chain \u2192 Session hierarchy is relevant to server-managed session modes.\u003C/p\u003E\n\u003Cp\u003EDo not assume identical session management semantics in PureJwt or other modes that do not enforce the same hierarchy during ordinary request validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protect-administrative-operations\u0022\u003EProtect Administrative Operations\u003C/h3\u003E\n\u003Cp\u003EAdministrative session revocation can interrupt another user\u0027s authentication.\u003C/p\u003E\n\u003Cp\u003EThese operations should be protected by appropriate permissions and, where required, stronger authentication or administrative confirmation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Login, logout, refresh, and authentication state validation.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/session-lifecycle\u0022\u003ESession Model \u0026amp; Lifecycle\u003C/a\u003E \u2014 Session roots, chains, lifecycle, and expiration.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/sessions-devices/device-management\u0022\u003EDevice Management\u003C/a\u003E \u2014 Device-associated authentication contexts.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/security/session-security\u0022\u003ESession Security Model\u003C/a\u003E \u2014 Revocation and session security boundaries.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "understanding-the-session-hierarchy", @@ -194,6 +194,6 @@ "Level": 0 } ], - "SourceHash": "FF79BEDC480EB6D1EB699C30604B98F72D86F0BE4187AF401B25DE17FBDE5252", + "SourceHash": "0F38795BE63F3BCDFA1D1E61B4E7A67D6F735C58869EBDFD5CF6118A57A46E2C", "BuilderVersion": "1" } \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index 37f393b4..95cc2b2a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -38,4 +38,5 @@ public interface ISessionStore Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default); Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs index 3782b0c6..e0b6ed40 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs @@ -13,6 +13,7 @@ public sealed record SessionChainSummary public DateTimeOffset? LastSeenAt { get; init; } public int RotationCount { get; init; } public int TouchCount { get; init; } + public int SessionCount { get; init; } public bool IsRevoked { get; init; } public DateTimeOffset? RevokedAt { get; init; } public AuthSessionId? ActiveSessionId { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index ff3b6e77..9145b450 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -80,9 +80,10 @@ public async Task> GetUserChainsAsync(AccessCon var total = chains.Count; - var pageItems = chains - .Skip((request.PageNumber - 1) * request.PageSize) - .Take(request.PageSize) + var pageChains = chains.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); + var sessionCounts = await store.GetSessionCountsByChainsAsync(pageChains.Select(c => c.ChainId).ToArray(), innerCt); + + var pageItems = pageChains .Select(c => new SessionChainSummary { ChainId = c.ChainId, @@ -94,11 +95,13 @@ public async Task> GetUserChainsAsync(AccessCon LastSeenAt = c.LastSeenAt, RotationCount = c.RotationCount, TouchCount = c.TouchCount, + SessionCount = sessionCounts.GetValueOrDefault(c.ChainId), IsRevoked = c.IsRevoked, RevokedAt = c.RevokedAt, ActiveSessionId = c.ActiveSessionId, - IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value, - State = c.State, + IsCurrentDevice = + actorChainId.HasValue && c.ChainId == actorChainId.Value, + State = c.State }) .ToList(); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 9ac9e960..58e7d514 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -867,4 +867,21 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can rootDomain.UpdateProjection(rootProjection); rootProjection.Version++; } + + public async Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chainIds.Count == 0) + return new Dictionary(); + + var ids = chainIds.Distinct().ToArray(); + + return await DbSetSession + .AsNoTracking() + .Where(s => s.Tenant == _tenant && ids.Contains(s.ChainId)) + .GroupBy(s => s.ChainId) + .Select(g => new { ChainId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ChainId, x => x.Count, ct); + } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index d18421e3..0b4225eb 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -574,4 +574,23 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat return Task.CompletedTask; } + + public Task> GetSessionCountsByChainsAsync(IReadOnlyCollection chainIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chainIds.Count == 0) + { + return Task.FromResult>(new Dictionary()); + } + + var ids = chainIds.ToHashSet(); + + IReadOnlyDictionary counts = _sessions.Values + .Where(s => ids.Contains(s.ChainId)) + .GroupBy(s => s.ChainId) + .ToDictionary(g => g.Key, g => g.Count()); + + return Task.FromResult(counts); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs index f9ed17f2..472cef8a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -737,6 +737,68 @@ public async Task RevokeRootAsync_RevokesRootAndItsChains() (await store.GetChainAsync(secondChain.ChainId))!.IsRevoked.Should().BeTrue(); } + [Fact] + public async Task GetUserChainsAsync_ReturnsTotalSessionCountPerChain() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + var root = await CreateRootAsync(store, user); + + var firstChain = CreateChain(root, Now.AddMinutes(-30)); + var secondChain = CreateChain(root, Now.AddMinutes(-20)); + + await store.CreateChainAsync(firstChain); + await store.CreateChainAsync(secondChain); + + var firstSession = CreateSession( + user, firstChain.ChainId, Now.AddMinutes(-25), "first-1"); + + var rotatedSession = CreateSession( + user, firstChain.ChainId, Now.AddMinutes(-15), "first-2"); + + var secondSession = CreateSession( + user, secondChain.ChainId, Now.AddMinutes(-10), "second-1"); + + await store.CreateSessionAsync(firstSession); + await store.CreateSessionAsync(rotatedSession); + await store.CreateSessionAsync(secondSession); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest { PageNumber = 1, PageSize = 10 }); + + result.Items.Single(x => x.ChainId == firstChain.ChainId) + .SessionCount.Should().Be(2); + + result.Items.Single(x => x.ChainId == secondChain.ChainId) + .SessionCount.Should().Be(1); + } + + [Fact] + public async Task GetUserChainsAsync_RevokedSessionsRemainCounted() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + var root = await CreateRootAsync(store, user); + + var chain = CreateChain(root, Now.AddMinutes(-30)); + await store.CreateChainAsync(chain); + + var first = CreateSession(user, chain.ChainId, Now.AddMinutes(-20), "revoked-1"); + + var second = CreateSession(user, chain.ChainId, Now.AddMinutes(-10), "revoked-2"); + + await store.CreateSessionAsync(first); + await store.CreateSessionAsync(second); + + await store.RevokeChainCascadeAsync(chain.ChainId, Now); + + var result = await sut.GetUserChainsAsync(Context(user), user, new PageRequest { PageNumber = 1, PageSize = 10 }); + + result.Items.Single(x => x.ChainId == chain.ChainId).SessionCount.Should().Be(2); + } + // --------------------------------------------------------------------- // Infrastructure / Helpers // --------------------------------------------------------------------- diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs index 919b77dc..d3a3ed79 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs @@ -4,8 +4,7 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; -public sealed class InMemorySessionStoreChainContractTests - : SessionStoreChainContractTests +public sealed class InMemorySessionStoreChainContractTests : SessionStoreChainContractTests { protected override Task CreateDatabaseAsync()