From 89ca94a7e777e3693362c84de9a14b861d840d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:56:20 +0300 Subject: [PATCH] Merge pull request #47 from mckaragoz/ChainIsActiveHelper Session Chain: Add IsActive Helper & Fix Logout Behavior on Session Reset in Chain --- Directory.Build.props | 2 +- .../docs/getting-started/quickstart.json | 14 +- .../getting-started/real-world-setup.json | 11 +- .../uauthhub.db-shm | Bin 32768 -> 32768 bytes .../uauthhub.db-wal | Bin 716912 -> 1133032 bytes .../Abstractions/Issuers/ISessionIssuer.cs | 4 + .../Domain/Session/UAuthSessionChain.cs | 1 + .../Issuers/UAuthSessionIssuer.cs | 23 ++ .../LogoutChainBySessionCommand.cs | 20 ++ .../Services/UAuthFlowService.cs | 2 +- .../LogoutTests.cs | 270 ++++++++++++++++++ 11 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/LogoutChainBySessionCommand.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 96fa6cdf..d9414fe5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.0-rc.1 + 0.1.0-rc.2 $(NoWarn);1591 CodeBeam diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/quickstart.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/quickstart.json index 8caf8257..3f6da51e 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/quickstart.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/quickstart.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/quickstart", "Title": "QuickStart", - "Html": "\n\u003Cp\u003EIn this guide, you will set up UltimateAuth in a few minutes and perform your \u003Cstrong\u003Efirst login\u003C/strong\u003E.\u003C/p\u003E\n\u003Chr /\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-project\u0022\u003E1. Create a Project\u003C/h2\u003E\n\u003Cp\u003ECreate a new Blazor Server web app:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet new blazorserver -n UltimateAuthDemo\ncd UltimateAuthDemo\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003E2. Install Packages\u003C/h2\u003E\n\u003Cp\u003EAdd UltimateAuth packages:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\ndotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003E3. Configure Services\u003C/h2\u003E\n\u003Cp\u003EUpdate your Program.cs:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthInMemory();\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-middleware\u0022\u003E4. Configure Middleware\u003C/h2\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore();\napp.MapUltimateAuthEndpoints();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-blazor-integration\u0022\u003E5. Enable Blazor Integration\u003C/h2\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode() // or webassembly (depends on your application type)\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-uauth-script\u0022\u003E6. Add UAuth Script\u003C/h2\u003E\n\u003Cp\u003EAdd this to \u003Ccode\u003EApp.razor\u003C/code\u003E or \u003Ccode\u003Eindex.html\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026lt;script src=\u0026quot;_content/CodeBeam.UltimateAuth.Client.Blazor/uauth.min.js\u0026quot;\u0026gt;\u0026lt;/script\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-application-lifecycle\u0022\u003E7. Configure Application Lifecycle\u003C/h2\u003E\n\u003Cp\u003EReplace \u003Ccode\u003ERoutes.razor\u003C/code\u003E with this code:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022perform-your-first-login\u0022\u003E8. Perform Your First Login\u003C/h2\u003E\n\u003Cp\u003EExample using IUAuthClient:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E[Inject] IUAuthClient UAuthClient { get; set; } = null!;\n\nprivate async Task Login()\n{\n await UAuthClient.Flows.LoginAsync(new LoginRequest\n {\n Identifier = \u0026quot;demo\u0026quot;,\n Secret = \u0026quot;password\u0026quot;\n });\n}\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022thats-it\u0022\u003E\uD83C\uDF89 That\u2019s It\u003C/h2\u003E\n\u003Cp\u003EYou now have a working authentication system with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession-based authentication\u003C/li\u003E\n\u003Cli\u003EAutomatic client detection\u003C/li\u003E\n\u003Cli\u003EBuilt-in login flow\u003C/li\u003E\n\u003Cli\u003ESecure session handling\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-just-happened\u0022\u003EWhat Just Happened?\u003C/h2\u003E\n\u003Cp\u003EWhen you logged in:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA session (with root and chain) was created on the server,\u003C/li\u003E\n\u003Cli\u003EYour client received an authentication grant (cookie or token),\u003C/li\u003E\n\u003Cli\u003EUltimateAuth established your auth state automatically.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You didn\u2019t manage cookies, tokens, or redirects manually.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cp\u003EDiscover the setup for real world applications with entity framework core.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EIn this guide, you will set up UltimateAuth in a few minutes and perform your \u003Cstrong\u003Efirst login\u003C/strong\u003E.\u003C/p\u003E\n\u003Chr /\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-a-project\u0022\u003E1. Create a Project\u003C/h2\u003E\n\u003Cp\u003EStart by creating a new Blazor app:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet new blazorserver -n UltimateAuthDemo\ncd UltimateAuthDemo\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003E2. Install Packages\u003C/h2\u003E\n\u003Cp\u003EInstall the required UltimateAuth packages:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\ndotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003E3. Configure Services\u003C/h2\u003E\n\u003Cp\u003EUpdate your Program.cs:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthInMemory();\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-middleware\u0022\u003E4. Configure Middleware\u003C/h2\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore();\napp.MapUltimateAuthEndpoints();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-blazor-integration\u0022\u003E5. Enable Blazor Integration\u003C/h2\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode() // or webassembly (depends on your application type)\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022add-uauth-script\u0022\u003E6. Add UAuth Script\u003C/h2\u003E\n\u003Cp\u003EAdd this to \u003Ccode\u003EApp.razor\u003C/code\u003E or \u003Ccode\u003Eindex.html\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026lt;script src=\u0026quot;_content/CodeBeam.UltimateAuth.Client.Blazor/uauth.min.js\u0026quot;\u0026gt;\u0026lt;/script\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-application-lifecycle\u0022\u003E7. Configure Application Lifecycle\u003C/h2\u003E\n\u003Cp\u003EReplace \u003Ccode\u003ERoutes.razor\u003C/code\u003E with this code:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022recommended-setup-optional\u0022\u003E8. Recommended Setup (Optional)\u003C/h2\u003E\n\u003Cp\u003EAdd these for better experience:\u003C/p\u003E\n\u003Cp\u003EFor login page (Use this only once in your application)\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E@attribute [UAuthLoginPage]\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EFor protected pages\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E@attribute [UAuthAuthorize]\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EFor any page that you use UltimateAuth features like AuthState etc.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E@inherits UAuthFlowPageBase\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022seed-data-for-quickstart-optional\u0022\u003E9. Seed Data For QuickStart (Optional)\u003C/h2\u003E\n\u003Cp\u003EThis code creates admin and user users with same password and admin role.\u003C/p\u003E\n\u003Cp\u003EFor in memory\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EFor entity framework core:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIn pipeline configuration\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022perform-your-first-login\u0022\u003E10. Perform Your First Login\u003C/h2\u003E\n\u003Cp\u003EExample using IUAuthClient:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E[Inject] IUAuthClient UAuthClient { get; set; } = null!;\n\nprivate async Task Login()\n{\n await UAuthClient.Flows.LoginAsync(new LoginRequest\n {\n Identifier = \u0026quot;admin\u0026quot;,\n Secret = \u0026quot;admin\u0026quot;\n });\n}\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022thats-it\u0022\u003E\uD83C\uDF89 That\u2019s It\u003C/h2\u003E\n\u003Cp\u003EYou now have a working authentication system with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession-based authentication\u003C/li\u003E\n\u003Cli\u003EAutomatic client detection\u003C/li\u003E\n\u003Cli\u003EBuilt-in login flow\u003C/li\u003E\n\u003Cli\u003ESecure session handling\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-just-happened\u0022\u003EWhat Just Happened?\u003C/h2\u003E\n\u003Cp\u003EWhen you logged in:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA session (with root and chain) was created on the server,\u003C/li\u003E\n\u003Cli\u003EYour client received an authentication grant (cookie or token),\u003C/li\u003E\n\u003Cli\u003EUltimateAuth established your auth state automatically.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You didn\u2019t manage cookies, tokens, or redirects manually.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cp\u003EDiscover the setup for real world applications with entity framework core.\u003C/p\u003E\n", "Headings": [ { "Id": "create-a-project", @@ -38,9 +38,19 @@ "Text": "7. Configure Application Lifecycle", "Level": 0 }, + { + "Id": "recommended-setup-optional", + "Text": "8. Recommended Setup (Optional)", + "Level": 0 + }, + { + "Id": "seed-data-for-quickstart-optional", + "Text": "9. Seed Data For QuickStart (Optional)", + "Level": 0 + }, { "Id": "perform-your-first-login", - "Text": "8. Perform Your First Login", + "Text": "10. Perform Your First Login", "Level": 0 }, { diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json index 24236da8..867b1a17 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/real-world-setup", "Title": "Real World Setup", - "Html": "\n\u003Cp\u003EThe Quick Start uses an in-memory setup for simplicity.\nIn real-world applications, you should replace it with a persistent configuration as shown below.\u003C/p\u003E\n\u003Cp\u003EIn real applications, you will typically configure:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA persistent database\u003C/li\u003E\n\u003Cli\u003EAn appropriate client profile\u003C/li\u003E\n\u003Cli\u003EA suitable authentication mode\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis guide shows how to set up UltimateAuth for real-world scenarios.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-entity-framework-core\u0022\u003E\uD83D\uDDC4\uFE0F Using Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor production, you should use a persistent store. In this setup, you no longer need the \u003Ccode\u003ECodeBeam.UltimateAuth.InMemory.Bundle\u003C/code\u003E package.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall Packages\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure Services\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n // or UseSqlServer / UseNpgsql\n });\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-database-migrations\u0022\u003ECreate Database \u0026amp; Migrations\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth\ndotnet ef database update\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003Eor\u003C/p\u003E\n\u003Cp\u003EIf you are using Visual Studio, you can run these commands in Package Manager Console*:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E*Needs \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Design\u003C/code\u003E and \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-with-options\u0022\u003EConfigure Services With Options\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides rich options for server and client service registration.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(o =\u0026gt; {\n o.Diagnostics.EnableRefreshDetails = true;\n o.Login.MaxFailedAttempts = 4;\n o.Identifiers.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-wasm-setup\u0022\u003EBlazor WASM Setup\u003C/h2\u003E\n\u003Cp\u003EBlazor WASM applications run entirely on the client and cannot securely handle credentials.\nFor this reason, UltimateAuth uses a dedicated Auth server called \u003Cstrong\u003EUAuthHub\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EWASM \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;; // Your (WASM) application domain \u002B return path\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(o =\u0026gt; o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;)); // WASM application\u0027s URL\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub Pipeline Configuration\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u2139\uFE0F UltimateAuth automatically selects the appropriate authentication mode (PureOpaque, Hybrid, etc.) based on the client type.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resourceapi-setup\u0022\u003EResourceApi Setup\u003C/h2\u003E\n\u003Cp\u003EYou may want to secure your custom API with UltimateAuth. UltimateAuth provides a lightweight option for this case. (ResourceApi doesn\u0027t have to be a blazor application, it can be any server-side project like MVC.)\u003C/p\u003E\n\u003Cp\u003EResourceApi\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(o =\u0026gt;\n {\n o.UAuthHubBaseUrl = \u0026quot;https://localhost:6110\u0026quot;;\n o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EConfigure pipeline:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthResourceApiWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003ENotes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EResourceApi should connect with an UAuthHub, not a pure-server. Make sure \u003Ccode\u003E.AddUAuthHub()\u003C/code\u003E after calling \u003Ccode\u003Ebuilder.Services.AddUltimateAuthServer()\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EUltimateAuth automatically configures CORS based on the provided origins.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUse ResourceApi when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou have a separate backend API\u003C/li\u003E\n\u003Cli\u003EYou want to validate sessions or tokens externally\u003C/li\u003E\n\u003Cli\u003EYour API is not hosting UltimateAuth directly\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-to-think-about-setup\u0022\u003E\uD83E\uDDE0 How to Think About Setup\u003C/h2\u003E\n\u003Cp\u003EIn UltimateAuth:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EServer\u003C/strong\u003E manages authentication flows and sessions\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EClient\u003C/strong\u003E interacts through flows (not tokens directly)\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EStorage layer\u003C/strong\u003E (InMemory / EF Core) defines persistence\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EApplication type\u003C/strong\u003E determines runtime behavior\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You configure the system once, and UltimateAuth adapts automatically.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EThe Quick Start uses an in-memory setup for simplicity.\nIn real-world applications, you should replace it with a persistent configuration as shown below.\u003C/p\u003E\n\u003Cp\u003EIn real applications, you will typically configure:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA persistent database\u003C/li\u003E\n\u003Cli\u003EAn appropriate client profile\u003C/li\u003E\n\u003Cli\u003EA suitable authentication mode\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis guide shows how to set up UltimateAuth for real-world scenarios.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-entity-framework-core\u0022\u003E\uD83D\uDDC4\uFE0F Using Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor production, you should use a persistent store. In this setup, you no longer need the \u003Ccode\u003ECodeBeam.UltimateAuth.InMemory.Bundle\u003C/code\u003E package.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall Packages\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure Services\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n // or UseSqlServer / UseNpgsql\n });\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-database-migrations\u0022\u003ECreate Database \u0026amp; Migrations\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth\ndotnet ef database update\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003Eor\u003C/p\u003E\n\u003Cp\u003EIf you are using Visual Studio, you can run these commands in Package Manager Console*:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E*Needs \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Design\u003C/code\u003E and \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-with-options\u0022\u003EConfigure Services With Options\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides rich options for server and client service registration.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(o =\u0026gt; {\n o.Diagnostics.EnableRefreshDetails = true;\n o.Login.MaxFailedAttempts = 4;\n o.Identifiers.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-wasm-setup\u0022\u003EBlazor Standalone WASM Setup\u003C/h2\u003E\n\u003Cp\u003EBlazor WASM applications run entirely on the client and cannot securely handle credentials.\nFor this reason, UltimateAuth uses a dedicated Auth server called \u003Cstrong\u003EUAuthHub\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EWASM \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;; // Your (WASM) application domain \u002B return path\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(o =\u0026gt; o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;)); // WASM application\u0027s URL\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub Pipeline Configuration\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-setup\u0022\u003EBlazor Web App Setup\u003C/h2\u003E\n\u003Cp\u003EA blazor web app contains two projects that includes host and client. You need to arrange them both.\u003C/p\u003E\n\u003Cp\u003EIn the host project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\n// In pipeline configuration\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIn the client project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });\n\n// Optional if you use external API calls in your client project.\nbuilder.Services.AddHttpClient(\u0026quot;resourceApi\u0026quot;, client =\u0026gt;\n{\n client.BaseAddress = new Uri(\u0026quot;https://localhost:6122\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EIf you want to use embedded UAuthHub in host project, you can register server services as shown in quickstart.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u2139\uFE0F UltimateAuth automatically selects the appropriate authentication mode (PureOpaque, Hybrid, etc.) based on the client type.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resourceapi-setup\u0022\u003EResourceApi Setup\u003C/h2\u003E\n\u003Cp\u003EYou may want to secure your custom API with UltimateAuth. UltimateAuth provides a lightweight option for this case. (ResourceApi doesn\u0027t have to be a blazor application, it can be any server-side project like MVC.)\u003C/p\u003E\n\u003Cp\u003EResourceApi\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(o =\u0026gt;\n {\n o.UAuthHubBaseUrl = \u0026quot;https://localhost:6110\u0026quot;;\n o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EConfigure pipeline:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthResourceApiWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003ENotes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EResourceApi should connect with an UAuthHub, not a pure-server. Make sure \u003Ccode\u003E.AddUAuthHub()\u003C/code\u003E after calling \u003Ccode\u003Ebuilder.Services.AddUltimateAuthServer()\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EUltimateAuth automatically configures CORS based on the provided origins.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUse ResourceApi when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou have a separate backend API\u003C/li\u003E\n\u003Cli\u003EYou want to validate sessions or tokens externally\u003C/li\u003E\n\u003Cli\u003EYour API is not hosting UltimateAuth directly\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-to-think-about-setup\u0022\u003E\uD83E\uDDE0 How to Think About Setup\u003C/h2\u003E\n\u003Cp\u003EIn UltimateAuth:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EServer\u003C/strong\u003E manages authentication flows and sessions\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EClient\u003C/strong\u003E interacts through flows (not tokens directly)\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EStorage layer\u003C/strong\u003E (InMemory / EF Core) defines persistence\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EApplication type\u003C/strong\u003E determines runtime behavior\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You configure the system once, and UltimateAuth adapts automatically.\u003C/p\u003E\n", "Headings": [ { "Id": "using-entity-framework-core", @@ -29,8 +29,13 @@ "Level": 0 }, { - "Id": "blazor-wasm-setup", - "Text": "Blazor WASM Setup", + "Id": "blazor-standalone-wasm-setup", + "Text": "Blazor Standalone WASM Setup", + "Level": 0 + }, + { + "Id": "blazor-web-app-setup", + "Text": "Blazor Web App Setup", "Level": 0 }, { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm index 3490745a1d6fba8b0dd9351709332192d43642f9..274b15c6cfab2fa0da1255837b1300c217392e53 100644 GIT binary patch delta 842 zcmb7@TSyd97{~wLOf&6HTdw&c*owOr+k;&o2tqPfiXe(us0|FFh#-q1W5FQEWub?l zmpBkTL{Jby47hr%tn9MgOEWdww2PJ5{eH2lY2VpdaG{6jz;F1z|Nno!GiP98F)bF; zYV-T2L9Jqj0B;^Y7IR%S*Q=M7?QDtH53X5WSvR?}MjlD@$^QS%tJ!L)xg%N2QPEJF z)WQZgCR4~1F@7e%sCW98XK^Nn@iGO>B*rJIX_n#U$`W%Zn5$-}T(+Mlc2oXn#;-gd zQ^u4t6-@R%pV&nOqxsyw@-n3Nmbzx58}HyE?gAvbL$=wD7tlzA^lf6e!ZvT?J=__) zYrbvW#7q2uPI)R46+;VbGldVZoQUjLm?HBIK4M%(XkWxa4?e+@5!#lNkh>noa2ogU zQ_#|bW-mTtykmQcki-KFVhB0vgaaZPj_U~==lZZ~HtymJCOA>UkMrnLy4<=fqFER4 z5MS{d-t<1XWkp2O8_=u*VsmBO?7%CGQQjfBZ)HUDoIxk<<28Q4Q0jiUcU1}LjX04W zxnVVLmpGYj)~{vfA|A<*K81`Rns7=c*L9M936C9ZDvqKhU5bG!TfdC{bR(NLmJpp4 zA-&F+hbwp@_iPGlbWRC+Ij(XjNV2IqMRd)n8>1;Qu44eh+!vEf)TGF2#Z!F42y)fU v3&}MdU3df2`3|CIyKT1N8UGLL2FNu7x9}EYGj3{!ZQa0gdT zZY~N5V*-h8E(nJ(Cg(+gm2I984V9S|2Qh7PRsvKcBUuMxC!;#ptjW*PAg*E5fbgGW yLS45b8|o5!u*BwVc~HIsgukT_=H^W$FxG~0D2q`KV%m*rsOkC;{nXfl(|OxlJ; zO<5`k-iydpyei;T(H9h4{-Rz*y()@=fLD;~dclROpn})!is<`1GdY2JbAI!j^LXA=b--YG=2Zs6F$TFDxc3jyz8!a+?*8E?ZaseY&;CiQ zGV0fl7^|!AzWIiMo~<3RwAwJ3NRC9)4GxjpWHMaP&>+&9CjZK8a*_EfGylrIWcH`~ zx!6BVhUV*<>ckeKa*jd#VL<=^1Q0*~0R#|0009ILKw!ZMO!$q}^){RFfF&K>Ijlw^ zp>StxS2CK8CE}@$ME~yTYfORekiR$7(CcsO2sN0eCongxsaxJ47IXW6xuLzQH?$$t z-O#nUx1p=AqoX0PxvQtQ+b`BPn73?-6wAM*yS>xjy}e;mX#4ufUG>IB)Koe;GHPz< z4Q&&_m55I_I{1Q0*~0R#|00D&beFtN>4UGMQ2 z4{RM{QdEctA59Q1X6_s0R#|0009ILKmY**5I_KdMK2)Z9Fg2)InXxt5L_KOs4p-?; zB+{wrYn1#^HPaL1hSZeu)X8G`bt7pS{C&Nf+q=XuJ40Q)+A}C+(WVX4(Khy~@n}3< zFxW=hC|x$G<1=gxB-LnI9q`YXdvqXwyLgPIw&!kjZ(_GTuWnn9+CP?zrN^fR0u?@s zQ`^a03G|Qb^lyqx$BSfZAJK&Ry4tt)g&NkFCso!rm?t+iiSb7PrKPJ1 znGthWw->a?7Bx8%%e=XDZKGScVy0HorOCC|tlY9BGrmQ0yDQ!z`fe_sOq*->T*j2S zLDvHcI(X*LqO0eUn`L)zUorQIQ>x|noz>_2QQtF;DHQuil z{J1<1wP53#xagMaMb4&cYHOJ?{}xYlI)RkeBhHcvw7Q{;?3CDvv!kA){W8BQ1Omnp8b41c^;ba(1D?7ES}q{d)IZVqr=gD zb)ap0S_yQi=6cVE_K(Iy`^@Xj z*rUUfJ)+2l>SFz(NV@#qQNqc$=st8Eq@+{vT#eeFRMWHciP==AwTP}Ywco-$MV?-* zmAfOFN{iy@J_6`>kQht$PyG_nZJ4W{^v&|Q;ZT1!Y^|=USI?jQcSDiZ(tcAE*;I4w zah28eE|>B84Z3b8KTgWek^PxRk!Q~^`RkSy>umW+%I;@{*^h7+>rae|cAV)`SneIx zJD!(L*W}@7Y*==av^p}H7Qc*U+L!E5T3YQKr`wHuvb#FaCwzyCZ0#1mQRE4xTaD(m z!pw*3R#R$PW)sh7pP?6LlFar#$s`Df$Ig$9%(jc!PMn!t%#EYHBWF7oZdg`Te~D}M zexBc3v@V{%wu<`*GII$m()$R+#kKQn@$%ZW;^l->#LMxk#7o|N1a&7@MephS^7pgP z9emQDJSkGJAbn$I^Yn z>DY+)+E#{xY}RILvrAMe^IgAoAHnDE+U#iB7yhukkHDz>$RPf(AbPd#_=q4V!IzWa*5Z05Ox3$ei|GXe-8fB*srAb)x`n$f`@zTSL7g&f*NtqEq009ILKmY**5I_I{1P~}&pho7<;sQVX`q@8mJ-YFC zj29@|kqihRfB*srAbUG=8l6 z*jv}U=7T(6plnAnAbMz zDKi2HAb@fUx3;DDR)0%bdr0RaRMKmY**5I_I{ z1Q0*~frTib$UMzDKi2HAb}xs$Z_K)_tw!v&(-} z_0g(JmIW&RT=CwDLE|o?+3-E_-;r9{kFBb|)MGTn;sfgb)Sls3T8%`<(uvrSI)K>E zjK`42jP&`((nFEHl$vZGP~+*?U`$P>BDSKdj|(VO^%vWU6j!&a9yOJUCF0$QM0#pJ z`gO-#edVg^dYjF7aCJJmb6CwDNuOf+8YR#j^7n=sdi`x3p$7Bx1m=b{b;}#XVs0NW zH?(*4hBkz{8@e|4HgxrMbTkAuclGpk`^EYO^OjAMb=Du!+_0s)z0=>ly-(Vn6`rmJW>nJa-F&tD8LlC6D26YA?~-`W>y zSYw`4S>Iru+|*RJyvbEpRo}jP_CC~|7*?k`Pv(bJ2ez%Ou3x>{c+EatugPr6_J+(q z>x%W0%$EEPlGldCd&guhUANG@W^L=P=!iN)Lw6;TBhlg5>x|noDwF5= zl)7h3jrXesKQ7OcDA>3rF8bGck+bQV+M4Qmhr@W#rN;;>`)u{bRH9bq8m`!zBi|ATY z`vuyQj*g7Z(BC?uskA7b?zgvo2Z^y{|I}{--G;gPN#88L@(=YSzqPumUOj*IU%N$G zOZy~PWK+$x$5mF>yIjWWH|V;Z{5UB;NA^e4;!{WV9F)IqS+UNRpQP-5R+#+=cd`D& zsA$K+kCj^I&Ar2V$Me$Znminh4a;tlR!2tDshRgBJCv4I>vOu@bR_!4=e<7RJ6vRI zxA=`BPcYqTG_MtAK3un&QqwYuKWYM;5^J2PCh%&?;(U42V3F)-Gjo?^;7QOgf}6|;TdD|(!Wt?24XaeY}M zuT>}f#>_?C0ZS1r*=zH&%QdFioMeI=lC&v&_Z5C@pMHS6&uQA5wKp~*`ug-Ux?mC2 zI6>A$iwivS-glUv`L8d$Ebk*&X&5rBc&7I0+JC9OtG2W9>FS*o2P@yd@~nyr#D*iZ zT-$d_b$wHl@dj0QKArr`GWok(`{}fNyXvNvhz` zvwaXKIJNlGncvZ~UN;BTy|LV{_kv>uhNH0&ah}h6>uG(kQ#7!+sE^LDdFp*z*yShl z>#tG51sCM0{NP6B6KzVnK%e@28{XUom4F^ zqORVhdwa;whM7zCY&V|z@#JD}kg2V?cgU<)8#4XOfty*cGyUQ^c~@Nj`!K(Q=-Y7W zI#X9Z{ku?4traN~Q^{Ox%FD}h>#ORwuA5zWYUi5n)QVHCdE*J0llS#!708NTB!l8} z%jC&NX3*Bu&*ZBm$Pv%^i!Il5xy-uyYww!FXsXHm&fJ#aSTdE?hh_Aixx`1Z88!uD zDREsmKEu|wSTa2n6u~>~eXiT-22})Cl0`PO#S%r9JX^Aqsw%P5YNB5OT?j)sKdF)JmN0;W1I!|8yBinllu2J)UEtxNh4v$TpFbcNi z0@$;qYENxR#zvw^@qNb(pI^*#^*iwaNPZ7hJgV|a1V@g@_gV)pdQ~PO`!@0ZEc{+8 z`?)6Xqjq-Hsl|Fw-shhD{!>U=cokIS5P6qD#Xr((AK;3AT-CnUDf(IVFn%k~)eU4U zq(@cbGsa8(J5!0_v9vnvgTLgDX@eO@jdbAXlf`i)Aw`!{&Z}wI_;uJ zSA_0_IAM!Ri9ljZen(i?gj!L06Jz~DbEGLawU=_4GTT*8IaeBmU;RyfRL{15WU2=h z`SMTO)-+jD{b%38>zB#B=5=G)|E8{Cw9YuwC5-k>n*w*2y-?xS53ml=q2fIN40R#|0009ILKmY**5I|sl3!Eg2r^N-%ec+cb zeCA_6{Tt60nBR>?=@38w0R#|0009ILKmY**5I8~t^>RO2T;Kz^+5I_I{1Q0*~0R#|0V15gn zB8#WR1wNYI`ux7jf4GtH0`t4kC>;U_AbJi})X}e>vi+fh$1+}Eem5GW zLjVB;5I_I{1Q0*~0R#|0;0OtvCikPo1+MH}8@%bCT0g~jfg_~LZV*5K0R#|0009IL zKmY**5SZTruaU*m;sSLSKRguse}}?Zv6OtAFHc*$Z(9oZKzpQ9jv-_+2fT@RIaSpYI=+DYlf!{Ze{bTtLi?s z^5vDG6{+RlSbkbi+g^kv{)edMMJZ4kp#qP;X+l8c#)f z)p#_Xjs%9Hv3UDHkrYjV?vTGX)X?4)3~g&Lm$Vym!{)9AbCI2y8`hY!M>MZ*Fi#%T z+%yqAy}Ewi`Njh$=NENLG?m(yNDc&&>VO(g$D+ei1@23!$xZ5bB&ZInX?4J#&K|8u zjx{q>ph!|pW7^L36)KW1zgm;IWeulFC+c{?>gxLb^Nka`%dO*#H9PB&Yj#NWX_^R| ztLt}(Dx^xTLbtkC^t0KT=gjgeF}2gOs*%gpG~sQmuHP;yqLy3{?Y&W?_0O2BL0?z< z*1qX)lp;B_e%3xvRt-dQ(FP6Wwn1qBXiU80XL*a9UVa-)?X;{KF-tkd2`v%F+w|e$Q$xmF$LG<;^&pqS2 zKU3DsiEirrC5tN>(f^w2devo|7%RK##p-IFq51NQS+tUeT&t#u^{0vVkf_7OWz?Zq z=^GEF^yQYjX=1~xtLt}*;$Bfoal6%&n(kKjjHxNzFPD7JhO)|CxW`f}FfE@rd7WBa z9~Cv(RZ2}d68+-*+b7;n!^QAlQ&uH(J1e#P`YcTbL&cT#O-;rd)O2*`@Z|fpM@^-~ z8$FdRp7zselLhPbw{?WH&l1{3@s6uo-XIpUJcG7(^@cWtx*NJS_cnC(b#ycYHh1;( zcKgNp2J@CplOKC>dAD@8clx`xH*5-RZ}9i^Zf@_I@HGok&ot8R#IU+WO^(E}CDh(Q zxmDRR7bLUKU|NuD`rh=W1&Q;|F}VVWbIBS@=^yhZ&!UsX)_)MR%~WW8BCW{UC!Eqg z=4q)#QP-DV)Xe8o?YC~;XI5)DMV;JD9<9Dg`LJ5*risn#s_PRD%NJ zjnS0+Y@PRUCogqN*~QM^WjR%lIpr^&sq%<^;#yl>e~Gvph?P^LB0Z&fvK}*h+{vr+ zG@-I9(KONAlr8rqC6s&eOFlk%BfC~U6xp#shoxHgyu6#?@?1bDbg%! z52+qymtOqZG&IO-dMz&SiO<&V{?|8KU&nX>sltK)0tg_000IagfB*srAb`N47ig3< zoQexXK5&cweNUQ+3oQC}r-BF|fB*srAb3zbzeJ9& zAub?QSP(z}0R#|0009ILKmY**5LomAXUQ6BaeuR?009IL zKmY**5I_I{1Q0+#3RvZf78i(gKfHVFn-~0*xPVk)K>z^+5I_I{1Q0*~0R#|0V9^WM zWDT{rzzOf@z9n_qpW28EEc$k*f(Rgh00IagfB*srAbg%7p=x1L$iMYU`Z+9w)00Iag zfB*srAbkgkyU5b zrB^<@vT?<>1W2j88;gC%KaSPGO;;WUBBCAJfOtl1M2?v zZIM3zSb8YZmr|2kl8M3Cu$qeWs_|$%omsa@9gj?|7yq1|wmr}t^7n=s`nuY;_JtbS zyMm!@4dya;Yi`)w)nJ~!b92KQbG9tz^XRbu%rU^%&x_-OMIH69jQAcc0 z?H})-UZ>o#a!EGMU6owo0;9qjtVXe>&?PIVKqP<9h4s;8EM6heC~^<7ai)qV+GeZ2x;`Ol zSzA^ur}mI*CS3u-hgQLo0Z~CwdCXt_~*E)KG6?x0);Yq!n2^A?eOu-THL- zrPWf4qOLE!sF{Md4-_e?wVa|(?k10R=Avq;n`kw+|F{067L6O)arFXSu8Edqios z&s|zsGVP3|-}Tw$7dE@ABC46%=a+RdZPP^9Rb3wwzX`4?TGUJ@DU^)_)xEKPwV>45 zq^+}ef!X(0r0CfU`DNFf#koo=i8xiQaTfhOF=<-#zJUQV$YI_Q~eLWD>|Z1{?e5R+_M*SmYwL!nMs!4Ve^kD`l`WN zU4O19rX?RX?+BV@W~Hl)qO}jqQ5=z=X~NPXt^>uV*NdkYA@@mf%1jnQUm#r)#|-^Y zX3-_nS~k0Bd=F?3kZxYw>E_p4l#RY!zh4fEO`^;GvGG5^DA{^vK009ILKmY** z5I_I{1Q0-Afe5%|IkmXJ{*PR6@ZEO|e1o{a0%<*pi2wo!AbqkFZ zdk=Ae1=4yH69EJeKmY**5I_I{1Q0*~fx{E<$?RHOpl9_Bw}0{G?{yOwI6PExBY*$` z2q1s}0tg_000Iagus{S_WjVFDz>nYco$BxOBu9x0ERfctm{?u4%odG2_MU%fCN6MzsN_Zf0R#|0009ILKmY**5I|so2wWh` zsl^5Uz3Kn$+8p@yUx^DWkk+G^2q1s}0tg_000IagfB*sr9G-w*X4m2ZSH!M(;tZo93CpU5kLR|1Q0*~0R#|0009IL zSRevHSxzl3@WE3b+x3+_!2#j|3#9cZCISc`fB*srAbAed;pe0*8l6ZUhiO009ILKmY**5I_I{1Qv)uSe8?Z3$$Fb<>3E!-+e>G1r|u_ zQA`97KmY**5I_I{1Q0*~0R#?DV1vxA#RaY~pY)S=zxvqohzlGZD!CCr009ILKmY** z5I_I{1Q1vt0vlyHwYb1DhQ9Lg4&nj}r1dB!0tg_000IagfB*srAb+Js`<+@t&5&5lf&c;tAbf}LJiYRR&>ODNc6!lsGM!Rq?mHsb+h zdbzeF6N9noYLUtHd4O8l_Q{g=b+vEpD^95;EocngZVSt=iThy@xbcorEVWk`i+9W>GbjZJ2T;PhP3kL$8{u>xCFyBAyQ9c9^KmY**5I_I{1Q0*~ z0R)bSfJy9SDlYJ=N2-2z%UAEYS&kPl9Ahvj-#;S9U?&J5fB*srAba^G$4K{D9!`o_eG&?;`r_1YHXSKFkt)fUL%3^8b1-|yxPu=nN z8?OJTj0+f*w;IGB76cGL009ILKmY**5I_I{1Q2-T1yaW4eT|(pH35U6#_qKF{qBIr zVhsh_EY7yD&*BdTLYA;M9JGsz`k<%H5s9Q?@m<4eBodutDHa`YJ3Kb8(<7>NlB}8* z7YN^d&(9yf@M{;#`v{E6QwH&e1px#QKmY**5I_I{1Q0*~0R$G9z*!Z2wWS0XM71`G zzf<=S-2LP)&yH+*c5OB;@VG&Fe1TP*;v#?m0tg_000IagfB*srAh47LnkySSYfB$W zAgJmkmo{F&eaWjm|ag6bJ zajU?o2E$pCdzJSLxP3mK&D!R)xZG`ii__~3So~pIz~XhXU)ztKWIkD()jVg6+<(5I_I{1Q0*~0R#|0 z009ILILZR@K7vvL4x(7?;_uXb1b^B2a>r*Me94rJ3p`{{9y-be=WqxhfB*srAbH2D4Fr?p3dDhql?T(t3yki*^VN<|H(rswkKlC%<#pv3g&YVV zfB*srAb*Ndr_4T!Z&EkfEU^wItTkUO@kVo7#;I#W(7N5^5 zUc__*Hm4&PaJ#Lg-!KrBcM4=)X1PsZmQ7Rh6dotrN{b6@f9JvHY+Ek;EB6u5gAqUg z0R#|0009ILKmY**5LmbZ@;-u6;t-;S;u#0IxWG4l7P#?!>+T-S#s!`?D9c*l^=XT z&QoYwb*q@CP`t1pfB*srAbF{t4>k8EMvAYUWum`X zHf84kT!`}&{-r~t9LvT99u)HwDi6+B8a5z+00IagfB*srAbc7p9#oJbAb|)YEV>wZQ<)*`)r*P+e@BGt1@cVy~^AuLp z{US3@q4*yQ0tg_000IagfB*srAb`Mv7MNIHk(v0fu`xUGpUduO3j|$Wi$CmlSe))Q zzs1|;4_h2QtHbN}xq>dcSN9mZImWJ^V6!`%F1N?)v*!La%PfAgY?_?lk9i6skvY(# znRyj6FSB^gvMEa~y7h6gYah7x57E9Icb&fJ&$aiR`MtU)bmjg7iz4dljVR2;c^3cviW$A0?2lY9HKae=!s^Asv~ zm0m_tB7gt_2q1s}0tg_000IagfWQ$KI91F=sE-NgZr!LjqVp6!(sNF)Ke)k`jSK9_ z%u}fBIpXzUhX^2m00IagfB*srAb(MY3^$ zSZ1C=C3a*L*c$=}Ab`FX@fr4n0BYl z?{^107HcTbW^uNKeHMQ(5VC~5;h^2+bp}0cx?|2YrQ!5rI-1=MkIn1!h-N)WHmw#H zh}?L|T6fjId{K@UFe&#Ml&8cC3jzorfB*srAb!Aj!U0xHop4S8?teMJ2TH6oDUVJ3$Q4ZUYpYu zF8!{60d+7sHk|HBN8^@(+x};;~dZ8BHgOlt_Oc!K78khhw;#Uw z-S2L$;XZVB3*Np>{ z;{!}?nXfjNuf%Z$qT0vGs%zr~UNZbadGyq0u8{W;RMv$J%Ij9$TK5a_4;BOvKmY** z5I_I{1Q0*~fmsA5POGew&#yo6#9Du+7#eO3c|1W!o5SJ|{}O}My$*}l>Iz$eK5xkC z4cY^4n>Tj}H`k@#m>8Tc|IR!>edaCetkzbmbz*&mET6Hlu{N+-l+R^%v;~4LuNbiI z7lY~L_;qiaKWuUMtPZc==L)*)y7J9+iFty}?r^%?98T zrz03}yRD^HBU9eY%Pa+%WmC3{qEjCyJGK@V=&1VO-Cz93i|gcgflB34gL0{IopP`8 zluW~d00IagfB*srAb~mQerg~GGQrElFWSsrNkjbZNxJU za&duAB;NRe%XXVTmyHX&%b?t&Jg+>cysL~_lN1325I_I{1Q0*~0R#|0009IJQ{ec@ z#uIgS6UgBrl_ys=HtN>p#%5HUQQ7FzZ7DwV!6f!sufNkk4x13g49cQv;{`7H!ME-| z|25w`n2ig(-Jm?Dyj}TUzT)=No9#`&E_9$W| z2NnbnKmY**5I_I{1Q0*~0R#}3CeTtTp4*UnD!^RNil{6|Ki9($D#}c6Y8K_u4+@y$ z0HQvbX9{X@0q;B3pQZlG%^%Lj137k+-c9!KPYo=V~ z#Rc-_DcnAEy5rW3pZS@br*PTItEc8E6#ryF009ILKmY**5I_I{1YRkDi3^rxW~K9Z zvXlIU>_Lws81`Bmes4%j^5+zj{MiD2i!JDLdj0OOJrJ~)K5%=vd}Q0SN$HBNoIa_W zv)Ssk+U>5Hw@l7R$2^71Q`l;onu&4Y&QthrA0GeuJGOr=Gf&|%#W^)kq4*~Y0tg_0 z00IagfB*srAbSe~yT#}BS}i`0FX;05+rn0>tMsY=PVjj>ZkN+xw^?)lnt3X}nYT<% z=f*sRvtNkHC)3HlGege2Ws-P(h1MSen+=8c z0-JU76e?n%2@3)UAb{8kh6ONB z;mx}YU+P}BDwvH6Jg%FkaMj~Wx;IgI1Q0*~0R#|0009ILKmdV-AyD?DgC|s#on>*& zQ&=q<_Q1V=i1zKc>-5XM@T1@U?dcbPA?GPHD$5Px4+{baAbx;KW~wyxW_f7jkl z&*s4Xp^&$IY~!}e_nqr>*uri0P{`u2dczi{*B`KW9ZvCL4+O%tfIn<+^AwxL(q;8n zT^^@s*c#cmT3q1W&bR;TnqU0CoiZF?RGu=3KP(6!fB*srAbnRmq&1Q0*~0R#|0 z009ILKmY**5ST{-_R7XS-8H{9CSZ>70%yt^XmNqx{rf$}Gj8d91LFneQM*zU1Q0*~ z0R#|0009ILKmY**j+DUk@d9VbU2AcH@!sp-RNHXYv)QYpDlM_3>#j+!@5;hA^+am|f}%O>PJg{D;} zh*S}NSO*|>}#7`Eb{*aFWR=;v+Q=xu}q(_&mJ1+ z4{qF*Xz5P+{Pu)zL#Q|1w@b{}cVTGj;HKb~L?Y1{9&qfoZ;5WYcwg+Y?fct8(eb@o zY+>uxf#79>w(-mT=ej*!U)W)DTU;S;$l|nl+bn*!&2Djo++L^C?|0dq&SEq6xm`Yo z!)|Au!oD4Moxb|Z_ka6FLrdm90+VvDn5R&@upoc{0tg_000IagfB*srAb`Mp5Li>u zXVd~Q1@W28kJ>iVq0UqIUQ%F|+=Lh-_a00IagfB*srAb5%Urnb7MJ7)1Nz-H&5Zo|M~fMOf;OmI~y0cP0UlM+_o?)PN@+<009ILKmY** z5I_I{1Q1xj0uC|fpzeBJ8wfD#Ab}$=PvNKk+;IH++x;J5yubqPI}{xO1Q0*~0R#|0 z009ILKmY+vVD9k(dGi!TE6=I8{s%X`BO4dEQ_NGS+^Ow~|093^0tg_000IagfB*sr zAb`M87HARk4(i4Y%<)Laou4?KFi+tMd8h;T{vj&oBlz8A@7QhQMOoXu{J-R`n_1llSIr;QgF{_3x* z?ccrq``PgVPZ^Y_7AY&$LI42-5I_I{1Q0*~0R#|0U=a(PRnb?gg$ia1BM1V!WYFRQ zpMS@<-t^`*$G2q1s}0tg_000IagfB*srEJA_fD;w)|!wE8vBox)m zn~&hHk!#)ezO;LvTxn9mVm<=#!h!$-2q1s}0tg_000IagfB*us2<$M4IRj2SQO+4) zpIj{R{{=7Fw%nWneJM5B*AwcVWyXM`J0HO%>kj<>w(o?N$?*av7erZ$u zFVAG-0#AwO3n))5DR(N100IagfB*srAb_^7#Tr<)01W4+{baAb%z0TrBec1uxpRS)N`Xs&$I2nid!Mr}w`117|(H;f(Bj1os$}dyZ1YISc{_Ab@}NwoM%HLU~XvF0l96&08+`^OZf> zxWFUg`2w>RiH!&#fB*srAbA`2roH_#IkY;7y(BzkDw8>DOfA z0*@P%$LCAsCdA@-79}5BqAbFDBS6SdA`66zrASHr!P71`)pi5%{*T~Q5R{`Q!NA#KmY** z5I_I{1Q0*~0R)bQz#%*#6$R<%8hCIB&lgxG)eqeJhiKo9yG}pzmm7Y3<&WNd zx_rKXQMtn){;(i`00IagfB*srAbkN|OkLog=`0tg_000IagfB*srAb zc?y*~=Sx*62LcEnfB*srAb`@Vm!2NIYLbDqL4-}Ybb7S9iE z$;JiVC*~Pg(DOBDwFDNJk0tg_000IagfB*srAbQcPSIkqm#5x<*MF0T=5I_I{1Q0*~0R#{@6oF~;6xNn9TR`qHfOFjza5UyA zltTlio;&#L?yLW``wut$T*d_|lm`sTFU1oE9yk;l@*sc!0tg_000IagfB*srAb`Lt zD{yQ@MN@5`e=I!|=}}XuSR$S}$yDLUUlkaN#^R|HjTN^1_1%d?Iwi`d$TDi<1-|jY z>%QG}UDxkqT)?Qj)gb<`AbGyXJ zw%W{BU19DYj>bmB&sSWv-keMftKy%RTq=&HCP!k~+X&=}tH$^3zkHiJ<_Zs8I2?#> z50Bag+uB?^cU!k~Z|~S|kHk9qJJmf~x+0zZqHDiec5f{%FgW~Z(?jnbypQn$i}y`W z%@9BU0R#|0009ILKmY**7O6nNc!5&FJ3`_#siDOM4qQ0A=V{gd`|NmuTMc!$E>bJ1 zg#ZEwAbe;W%_Fh?WFnOqOgHx@Mq2iz zTI^P4X@q zAU9tn-Uc4|R?y-CcMaarzQ5&;|6{zsd_F-?HUtnr009ILKmY**5I_I{1d0e8+IRu4 z+>;g;_-EHQ&brx~sv#~=#FteFAb?VTSGKmY**5I_I{1Q0*~0R#|0;3x}RvaGRF zE#q3hoGBGVRnC^x(c%I>`}|Mdv;9@4KEizjN4b$X90CX+fB*srAb z3$*<0&nJKB*&UzF#swZWC=VY_fyj#h0tg_000IagfB*srAb^cYii6@S;I^ zQEp{H009ILKmY**5I_I{1Q0*~fkh#(s>Pzpv?*6}J;{p#F zlm{0@t*Hb82q1s}0tg_000IagfB*uESio1=*smKOFurg9<=fmbS9s{c;XrhIc+@u7 z*5=x|+q$KDd&hozB-YX2sqWd*73u63C~e|zhZYxjU*CV6_xvfr(QI7cF@y5hB4(p% z2q1s}0tg_000IagfB*srEHZ(XWsRNs@c_{|7E%3LNub3Ae%1Tw{!<2D|J^#V&baDD zgZRUO00IagfB*srAbICRd__XM-|5j?yz#y;I+21*%aAP%rr9z=@^JbJ_boc!PWPi@M^1(>I>yapgO0tg_000Iag zfB*srAbX9$R~v`!zq~6O7kJU2@Z7*n>wYb307wo^k;WKC6nT-oPXiy%U_cfw` z2q1s}0tg_000IagfB*srEO7x}Wn;hY!2)H>bRgiGC7u=+IOjJf{vz_fr~h*{F7P#j z^0g&SOZ5>z009ILKmY**5I_I{1Q3|-0P~#Rcrwz3g9q z?X~RbsvHGD=&TK;rD%XXPxqZp;EcYp!`y~NqIu~ zkW9mZ00IagfB*srAb{Diuq_14GeRJY_nmvcgfD z-O#NLCe_qXZ(_F^Pl>YBPp+TLAXmNWxQdFV{Qaa%CmJhk`ANGIiL^|9s;rTAAHi|U zUwiZZuYdWAnehTE{$!~8yLe$i009ILKmY**5I_Kd#VByiGSl&fKJiUtAP`Uw?2h!; zi0?dWeLj~r?33SfdfY*a)8TQ88v37^L6=DA^tvrx zkIiNAguJ$ZGvE$6L%!0#uC!b2Zj04vu{#=U-d2aV)#+V@Y)|v0to#F)y~iFO*t^N%zSz2F!;a0Wy<>P^SGV8Q2P(#d|{kY9QS{634_-R8D9?cx@7zuRH6gk5%DP&ACo?G3-e{lMXD z_SoG{htD~!A57mg)emx;w)>)~Z5wReJ^pcjN7}Y=Ya$rh(7x@$v8}<*@$J4{n?0dm z)OlHtvv+rXKPV^TE7A}8hmwgAwV)fcwwBWY%A2R~)$#R0R#|0009ILKmY**5I_KdqbuO3Z0s}U0&Jy(>`VpE9V{ihBPw;8te6%T zxZ`c_dFI9s{@YKp;{{e5>TX@J`sh}iqalC*0tg_000IagFfRqJNtli`^k=?XH`E;1 zeRbVLT}>HZ$(HhoKKJ>5t~-vWeS13n3-+mRPjj0V?l=dPmHweOSRaoR&R^d-4Yv6ye0dU zdyD+GUGB|4FcOQ4?`HetclKg`*`wvYfR}03nlIITKR+!?F0uT!K9l+ib98j{A~h*T zf}308W5dJi&BKZQs61x&r~XuOFf$Zay!>56p%6d- z0R#|0009ILKmY**mWsg98!zCMq*`3y&HF$8LiCpZyMwsEQt4>a69EJeKmY**5I_I{ z1Q0-AUJ4Y%1xk6GL*ab{rQ{KH_sRNeae?PvIP=u&j(`1UvvGkt4a(!no%7O%LLqb=+1z2l}i~s@%AbuF}$y<+wbb}d51b;?K`3&`&hL7Vt-;hWb%ou zx{Lksefuxp=8n0-Ll+JQqT9oxw!yYG*UsJ6E#2EY_S++|j{Z({&z7!8XTLyd6Ms9j zxInF8cjM}t4Tfx7;C6%Zm~#6YYg9Y?`wW4Ap~h}=*qq{ybBiq`UYzc5z~c3Vd={6>7xcQFes?hBibPVe z_^x3!5*bUi_&2Q^AM14be0#cjcJ%MKxYN@zmTb8&6yEOj1TVkPanXgNp|OD-?dQ&Q z4O+%G;H5lgPrGa0?r5-iTOHn3af7|v>2NvS4sDaeI&IT|mBu`dfVa)<40r;Tu-9s{ zIBg!grOog3TLNLP-Qn|D9S*xK$I<2KaI{&&4tvzPqieHI-MV+fg?oG4U7JFCN4jDi z+t=;wbdT?AZ})XL=j15kTm4d=AgI-Oio@Y*w%TkqtJ|(^n&QZ95*=F(4Va1xT=yT( zzWax(2F!KJhYXd%I6pG8-SuCfB*srAb2b>R?h$4fQ5=tM!#r$#rW*^5ZHhn)3IPGM#9w zuubk_a^KyFL^>t*+$3?d@d6)x`TCdLFTM2o?0p2c8I(tq+vIi@1Q0*~0R#|0009IL zKmY**5IBkgj>^V9-O~n289-sOi#)n}2uc}cAZoN$)=G;Dw7q;|{POhnQ?qda<|#ai zEzY43KmY**5I_I{1Q0*~0R#}3O`wE%3MX%)n0ubWsap)h0oTccYH@*EZvVCIr&s;- zPuaM@M-1W~f{)BrD7GPh00IagfB*srAbIM!>OPlw6f%UTVT3ldU{ls6t{>j(BHyamt+Mv9sJbe^u#~~0v009ILKmY**5I_I{ z1Q0;rl@L%W8};D?(<uTlUMcwj0R#|0009ILKmY**5I_I{1dhDG*_Dl|E-FyY{E#LQQBZYZ zfpR8O6cuTamC@n?Z+QQcPyYD2`+Bo+frAF+tIEM6Us?8u00IagfB*srAbdj?11lJyC!5ce8PU zI}OU?%AH4}x*P)m1Q0*~0R#|0009ILKmY**iU_n+Hg@XnFNn^un3_d;-PnOSl1If6 zPnAd2;sWUpK7IFnBZ2>}6YESX8x3`T7cVRbAbpU>qD`+`1;)8h_WoDPrM(suPF`6&%DrLkB_zbj}7xji1Q%N1}t zT|v9e>+pL$E{i?r61#MI-4?IM=CXJ~UR%H!aEF{BUnG)>#di&>k;q&ZFxp&ec1MHF z+v@PPih0>wPOHaZ_Y`dMwOT#Rc9+LvcX_o<4(qgBp}q^d`a|PmUA<%KV0^Q^ZBVtl z!tvhT?TMYPQD-n@_jK{igkmrq&k?`FV@9I z{R0C@HI));ZFWzyRs3f&Uv-7Ke>fT&5o@owYP~s`7*@qUFS%44R85Y=QmI%Xo>_5~ z>FPHcb^YMd$$sGQIvnAE-)FJA+uRnXU3}&2cROsBu*>cX3T~I%8-9gb*WqmT*xgQt zPku@6wpg8(sfM4vX{vQ|o3{I+scjo<-97$se@EK3acd$N+R(o3!m+Kv&hhQOU7J0j zVAOe8kF$4oe(RQ#@fB&^{-I=IL@j9C*4A>GwY=vJo+eLlT3ldv@Qputar|31XW{}Y zY7BL^iWe3H5I_I{1Q0*~0R)zaz_rUw#~S*x7s@pUc3)jLQCC~W6TwTlPCla7z*Cn( zC0!mb-0NENSad8s)S6b~(Rf;1+?sQWW^tiAlunPfwzQ=Bht!d1s(B>VpG>3@gX!k} z#7N7&REyne^|n~uEwKSLo{pu*Te26-E%I7f?#(|i5{rv#us(UkE%ui^TJG{$rdeyg zRCjefElV!3yw=X7zQPLA$N)GlWcB@mH`^Qr0 zga`!4%~xGBWYjvNq2|D)vNP6{ajjR%WqppZ?0`(};#<39pX!XcO?f?TVO(o3$*v|& z@8`)=ycQQY^Y7pLUiiH4v}ErixYD3JtX#Q7eg#lX1Q0*~0R#|0009ILKmY**j;g@s zWsQ9S-8}-O1Q)eX%;e|^{n!|jL+nrg{J~P!RQ}TnLrb;?H@D{o7nbGC+HX(+n$SK=ivFgI7psf>WF2tWV=5P$##AOHafKmY;|IFG;`p6lq4Bn@;VPG)v=K3{|G4A~fo zsGJmY#_!}cjf<#pm5Zu+G{$Q}ENU3FkL_>``=thF3qGg%(iVJDRFHK&p>a}Fiqqjd z<1(ikqR7Q%x}9iD=A$8@_AStZavlw+^dn&JOMLjP_**YDk;fS?InI!G$Z_&2dG0(1 zk693a00bZa0SG_<0uX=z1Rwwb2wX~m^&VDi7>Lk;Hi!CXk_kF$r7@DsWNi=K$hy^~ z1-<4lO+Yc?3|hI~TUp&Tm#%(2%Qi2p$7XMAVEMT!=IZXXtb$EzUXZ8o=p`ILq3x>?Er{HBtPj~5(5B5yh;~`NU?#P=R+?{%QXh;{8#MIdB5n(K1 zwngo<(bhSUFnZ1HEb@uX=K_|P40(y1pl_HU009U<00Izz00bZa z0SG_<0uWfLfavuPxW*N%C2Y+up^QhM+gUvy@UAgW;q17A<+#9CceMWO^xJ)7rMN&V zLr#-ca`#dX9OWPY0SG_<0uX=z1Rwwb2tWV=SA~Go=pSI}ZTfGch1_`vYKa$|^YB_o zZ4qQu6=cmLIy+H+T;L1N87jvG9{9uIKY#k`-q%ZUfx`@WjeMURzADZk4iW+ofB*y_ z009U<00Izz00bcLkqd0_`rV;{amV8Ogy! zjvtOj<$a@}p5DRE1Hw?IGu35I^mGq(rKo6>zBqOJ(8rLMNgtV6 z{_faA2tWV=5P$##AOHafKmY;|fWR^l2sQe<+;{Vni%e_>E#U4R;d)AU4)lQc=@bUrF`a)gg4k}4+*!foPLp%gh65AIw#9|OjD$Y5{t|897Q`A>DV}3 z?2>eSqPuT*YWL1AHCW6C+vABrO^w~vF79a0#f$0PZHsc$aR+<}9J3wlX`gvnQp73_ z_o5?f8WwOET2zq?)!-6Zi033;6}V_bif~3k6GUAPiK4*Ib9AenVlh86yf=z2~cC5(d!>@4LVTE!w~Ee%6J629akTMTAnhX$FspX zrgB{1(Gw4?I&yl={!&~3c?y?wzEKDQ5P$##AOHafKmY;|fB*y_P@}-blMl}3DV*yM za-pOHmCgY)PvKlQipBF3R=OC_-Q4KxYB?_O#fOLPYCL)DiBeqP5JUSAJV6fCcraKE z0uX=z1Rwwb2tWV=5P$##AOL~d1vWPN4fhCzRpS6GhzcySu6qat&jzPn*MkF9^;V59 zu*uosa$F#|b+fVVp$B87xWFq6IYa(PUa9?%uqFf`009U<00Izz00bZa0SG_<0#{Ig zc>V5B16%dIXDwas3K`Hn<($1O#|5^Y{bAREH-7r>Qe2>iAwA@Na_R~m4>k<}2tWV= z5P$##AOHafKmY;|SV00|uRrA)N}!I66l_(|IEPnY5XT?~1Vbdmd3&O6&p7F8re zHMoQp;yH;|1uhzqBAk)X1X0&RqA2iG%XxK{;{pxEzx?Tquf4aY6c?Ca$SdSAGO-Lz zu}ctu00bZa0SG_<0uX=z1Rwx`l_(JO`nz0x4U&sYY@O?ST4?>QX9pIkK1m60b{NZX zfsX6m{rh*1J;^oEd7d?Xrs==*3nmCa00Izz00bZa0SG|gV<<4Q%5wuVFyQZ^cP7mv zqeCg5&&M?Ax~wJin9fP6LhoaWszUEC9^JEWRp!F0$}ujksH!H*hC=U%3cMypG*#w= zm`v-GG=omcnePxt6}T+rcHaaVCCrS+*qARVy^M*5dbJ#jS=Vfw{bUJU^Hl520YDT+eB( zZ`{F8Ilth_aehWQfwm_C^NIo5wiqcCa^YamPK}u3NxNk{lgeARHC$*(S>wSeJ1B%gS}>#p zGifth$P}i7r8Y{?X`VQ>MbhJ$EdAI!;52e*eWk61?y~UCPc6_gkZ8WpS!#7cAQLauy%D_ZrVR7h@eG zf?w8)@qBIIN>fPETQ1Z*4yi4Itg3>n;bL4-7vs!GL-}H4d`I>=zZrdXwDoFf*3bV; z*By|VZ+xEAup2|qJ4x% zjZHxS0uX=z1Rwwb2tWV=5P$##R)9c9BORQ>_3S{My)oQ9&e;FEE?2WZNcYR#?q6)b F{s-4MHYNZ7 delta 8990 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 diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs index db00ab92..6d11566f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -17,4 +17,8 @@ public interface ISessionIssuer Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at,CancellationToken ct = default); + + Task GetChainIdBySessionAsync(TenantKey tenant, AuthSessionId sessionId, CancellationToken ct = default); + + Task LogoutChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index e211cf04..04ac32cc 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -24,6 +24,7 @@ public sealed class UAuthSessionChain : IVersionedEntity public long Version { get; set; } + public bool IsActive => ActiveSessionId is not null; public bool IsRevoked => RevokedAt is not null; public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 38cf330d..7c0c35e0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -293,4 +293,27 @@ await kernel.ExecuteAsync(async _ => await kernel.RevokeRootCascadeAsync(userKey, at); }, ct); } + + public async Task GetChainIdBySessionAsync(TenantKey tenant, AuthSessionId sessionId, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + return await kernel.ExecuteAsync(innerCt => kernel.GetChainIdBySessionAsync(sessionId, innerCt), ct); + } + + public async Task LogoutChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + + return await kernel.ExecuteAsync(async innerCt => + { + var chain = await kernel.GetChainAsync(chainId, innerCt); + + if (chain is null || chain.IsRevoked) + return false; + + await kernel.LogoutChainAsync(chainId, at, innerCt); + + return true; + }, ct); + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/LogoutChainBySessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/LogoutChainBySessionCommand.cs new file mode 100644 index 00000000..62ea3e68 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/LogoutChainBySessionCommand.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record LogoutChainBySessionCommand(AuthSessionId SessionId) : ISessionCommand +{ + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + var chainId = await issuer.GetChainIdBySessionAsync(context.Tenant, SessionId, ct); + + if (chainId is null) + return false; + + await issuer.LogoutChainAsync(context.Tenant, chainId.Value, context.At, ct); + + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 95b91315..85011c70 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -76,7 +76,7 @@ public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = defa var now = _clock.UtcNow; var authContext = authFlow.ToAuthContext(now); - var revoked = await _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + var revoked = await _orchestrator.ExecuteAsync(authContext, new LogoutChainBySessionCommand(request.SessionId), ct); if (!revoked) return; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs new file mode 100644 index 00000000..436b300c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs @@ -0,0 +1,270 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class LogoutTests : IClassFixture +{ + private readonly HttpClient _client; + AuthServerFactory _factory; + + public LogoutTests(AuthServerFactory factory) + { + _factory = factory; + + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Logout_Should_Invalidate_Session_And_Cookie() + { + var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var logoutResponse = await _client.PostAsync("/auth/logout", null); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + var meResponse = await _client.PostAsync("/auth/me/profile/get", null); + + meResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_Should_Detach_Chain_And_Reattach_On_Next_Login() + { + var loginResponse1 = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").First(); + cookie1.Should().NotBeNullOrWhiteSpace(); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookie1); + + var logoutResponse = await _client.PostAsync("/auth/logout", null); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + var unauthorizedChainsResponse = await _client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest()); + + unauthorizedChainsResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + _client.DefaultRequestHeaders.Remove("Cookie"); + + var loginResponse2 = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").First(); + cookie2.Should().NotBeNullOrWhiteSpace(); + + _client.DefaultRequestHeaders.Add("Cookie", cookie2); + + var chainsResponse = await _client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await chainsResponse.Content.ReadFromJsonAsync>(); + page.Should().NotBeNull(); + page!.Items.Should().NotBeEmpty(); + + var currentDeviceChains = page.Items.Where(x => x.IsCurrentDevice).ToList(); + currentDeviceChains.Should().HaveCount(1); + + var current = currentDeviceChains.Single(); + current.ActiveSessionId.Should().NotBeNull(); + current.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Logout_From_One_Device_Should_Not_Affect_Other_Device() + { + var client1 = CreateClient("device-111111111111111111111111111111111111111111111111111111111111111"); + + var login1 = await client1.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie1 = login1.Headers.GetValues("Set-Cookie").First(); + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + + var client2 = CreateClient("device-2222222222222222222222222222222222222222222222222222222222222222"); + + var login2 = await client2.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie2 = login2.Headers.GetValues("Set-Cookie").First(); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + await client1.PostAsync("/auth/logout", null); + + var me1 = await client1.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = ProfileKey.Default }); + me1.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var me2 = await client2.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = ProfileKey.Default }); + me2.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Logout_Should_Not_Revoke_Chain() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + await _client.PostAsync("/auth/logout", null); + + _client.DefaultRequestHeaders.Remove("Cookie"); + + var login2 = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie2 = login2.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie2); + + var chainsResponse = await _client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + var page = await chainsResponse.Content.ReadFromJsonAsync>(); + + page!.Items.Should().Contain(x => x.IsCurrentDevice); + + var chain = page.Items.Single(); + + chain.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Logout_Should_Clear_Only_Current_Chain() + { + var client1 = CreateClient("device-111111111111111111111111111111111111111111111111111111111"); + var client2 = CreateClient("device-222222222222222222222222222222222222222222222222222222222"); + + var cookie1 = await LoginAndGetCookie(client1); + var cookie2 = await LoginAndGetCookie(client2); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + await client1.PostAsync("/auth/logout", null); + + var chainsResponse = await client2.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + var page = await chainsResponse.Content.ReadFromJsonAsync>(); + + page!.Items.Count(x => x.ActiveSessionId != null).Should().Be(1); + + var device2Chain = page.Items.First(x => x.IsCurrentDevice); + + device2Chain.ActiveSessionId.Should().NotBeNull(); + } + + [Fact] + public async Task Logout_Should_Delete_Cookie() + { + var login = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = login.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var logout = await _client.PostAsync("/auth/logout", null); + + logout.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + + var logoutCookie = cookies!.First(); + + logoutCookie.Should().Contain("expires="); + } + + [Fact] + public async Task Logout_Twice_Should_Be_Idempotent() + { + var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + + var first = await _client.PostAsync("/auth/logout", null); + var second = await _client.PostAsync("/auth/logout", null); + + second.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + } + + + private HttpClient CreateClient(string deviceId) + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", deviceId); + + return client; + } + + private async Task LoginAndGetCookie(HttpClient client) + { + var response = await client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + return response.Headers.GetValues("Set-Cookie").First(); + } +}