diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml
index 2240fa4c..e6d6635b 100644
--- a/.docker/docker-compose.yml
+++ b/.docker/docker-compose.yml
@@ -5,8 +5,6 @@ services:
dockerfile: .docker/Dockerfile
container_name: ultimateauth
restart: always
- ports:
- - "8081:8080"
networks:
- edge
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 37f752d9..dc07c26c 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -3,23 +3,19 @@
Thank you for contributing to **UltimateAuth**!
Please complete the following checklist to help us review your PR effectively.
----
## π Summary
Describe what this PR does and why itβs needed.
----
## π Details
Explain any important implementation details, design decisions, or considerations.
----
## π§© Related Issues
Link any related issues:
----
## π Changes
- [ ] New feature
diff --git a/.github/codecov.yml b/.github/codecov.yml
index 70dbff6f..c52c4b6c 100644
--- a/.github/codecov.yml
+++ b/.github/codecov.yml
@@ -5,9 +5,9 @@ coverage:
status:
project:
default:
- target: 50%
- threshold: 5%
+ target: 70%
+ threshold: 0%
patch:
default:
- target: 20%
+ target: 50%
threshold: 0%
diff --git a/Directory.Build.props b/Directory.Build.props
index d9414fe5..982a9ce8 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.1.0-rc.2
+ 0.1.0-rc.3
$(NoWarn);1591
CodeBeam
diff --git a/README.md b/README.md
index 930e8a4f..af08c023 100644
--- a/README.md
+++ b/README.md
@@ -33,15 +33,15 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t
| Phase | Version | Scope | Status | Release Date |
| ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ |
| First Preview | 0.1.0-preview | "Stable" Preview Core | β
Completed | 07.04.2026 |
-| First Release* | 0.1.0 | Fully Documented & Quality Tested | π‘ In Progress | Q2 2026 |
-| Product Expansion | 0.2.0 | Full Auth Modes | π‘ In Progress | Q2 2026 |
-| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | π Planned | Q2 2026 |
-| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | π Planned | Q2 2026 |
-| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | π Planned | Q3 2026 |
-| Extensibility Expansion | 0.6.0 | Audit, events, hooks | π Planned | Q3 2026 |
-| Performance Expansion | 0.7.0 | Benchmarks, caching | π Planned | Q3 2026 |
-| Ecosystem Expansion | 0.8.0 | Migration tools | π Planned | Q4 2026 |
-| v1.0 | 1.0.0 | Locked API, align with .NET 11 | π Planned | Q4 2026 |
+| First Release* | 0.1.0 | Fully Documented & Quality Tested | β
Completed | 04.10.2026 |
+| Product Expansion | 0.2.0 | Full Auth Modes | π‘ In Progress | Q4 2026 |
+| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | π‘ In Progress | Q4 2026 |
+| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | π Planned | Q1 2027 |
+| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | π Planned | Q1 2027 |
+| Extensibility Expansion | 0.6.0 | Audit, events, hooks | π Planned | Q1 2027 |
+| Performance Expansion | 0.7.0 | Benchmarks, caching | π Planned | Q1 2027 |
+| Ecosystem Expansion | 0.8.0 | Migration tools | π Planned | Q2 2027 |
+| v1.0 | 1.0.0 | Locked API, align with .NET 11 | π Planned | Q2 2027 |
*v 0.1.0 already provides a skeleton of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas.
@@ -51,19 +51,6 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t
We keep it up-to-date with current priorities, planned features, and progress. Feel free to follow, comment, or contribute ideas.
-
-
-> UltimateAuth is currently in the final stage of the first preview release (v 0.1.0-preview).
-
-> Core architecture is complete and validated through working samples.
-
-> Ongoing work:
-> - Final API surface review
-> - Developer experience improvements
-> - EF Core integration polishing
-> - Documentation refinement
-
-
---
## π Why UltimateAuth
@@ -99,7 +86,7 @@ Modern security built-in by default:
- Session reuse detection
- Device tracking
- Hardened auth flows
-- Safe defaults with extensibility
+- Safe defaults
### 5) Extensible & Lightweight
@@ -115,7 +102,8 @@ Designed specifically for real-world .NET environments:
- Blazor Server
- Blazor WASM
-- .NET MAUI
+- Blazor Web App
+- .NET MAUI & Hybrid Apps
- Backend APIs
Traditional auth solutions struggle here β UltimateAuth embraces it.
@@ -124,56 +112,49 @@ Traditional auth solutions struggle here β UltimateAuth embraces it.
# π Quick Start
> β± Takes ~2 minutes to get started
+>
+> **This Quick Start uses a Blazor Server application with in-memory persistence.**
+It is intentionally designed as the simplest path to a working UltimateAuth application.
-### 1) Install packages
+> For Entity Framework Core, Blazor WebAssembly, Blazor Web App, Resource API, persistent storage, and other real-world configurations, see the [Real-World Setup guide](https://github.com/CodeBeamOrg/UltimateAuth/blob/dev/docs/content/getting-started/real-world-setup.md).
+
+### 1) Install UltimateAuth
-1.1 Core Packages
```bash
-dotnet add package CodeBeam.UltimateAuth.Server
+dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle
dotnet add package CodeBeam.UltimateAuth.Client.Blazor
```
-1.2 Persistence & Reference Packages (Choose One)
-```bash
-dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle (for debug & development)
-dotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle (for production)
-```
-### 2) Configure services (in program.cs)
-Server registration:
-```csharp
-builder.Services
- .AddUltimateAuthServer()
- .AddUltimateAuthEntityFrameworkCore(db =>
- {
- // use with your database provider
- db.UseSqlite("Data Source=uauth.db");
- });
-// OR
+### 2) Configure UltimateAuth
+
+Register UltimateAuth in `Program.cs`:
+```csharp
+// Server registration
builder.Services
.AddUltimateAuthServer()
- .AddUltimateAuthInMemory(); // Development
+ .AddUltimateAuthInMemory();
+// Client registration
+builder.Services.AddUltimateAuthClientBlazor();
```
-Client registration:
-```csharp
-builder.Services.AddUltimateAuthClientBlazor();
-```
**Usage by application type:**
- **Blazor Server App** β Use both Server and Client registrations
- **Blazor WASM / MAUI** β Use Client only
-- **Auth Server / Resource API** β Use Server only
+- **UAuthHub (Auth Server) / Resource API** β Use Server only
+
+### 3) Configure the Application Pipeline
+Add the UltimateAuth middleware and endpoints:
-### 3) Configure pipeline
```csharp
// app.UseHttpsRedirection();
// app.UseStaticFiles();
app.UseUltimateAuthWithAspNetCore(); // Includes UseAuthentication() and UseAuthorization()
-// Place Antiforgery or something else needed
+// Place Antiforgery or something else before endpoint registration if needed
app.MapUltimateAuthEndpoints();
app.MapRazorComponents()
@@ -181,54 +162,89 @@ app.MapRazorComponents()
.AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());
```
-### 4) Add UAuth Script
-Place this in `App.razor` or `index.html` in your Blazor client application:
-```csharp
-
+### 4) Add UAuthApp
+UltimateAuth uses `UAuthApp` as the root integration point for its client authentication state and Blazor lifecycle.
+
+Replace the default router in your `App.razor` or `Routes.razor` with:
+
+```razor
+@using CodeBeam.UltimateAuth.Client.Blazor
+
+
+
+ @* Add application-wide UI providers or other root components here. *@
+
+
+
+ Not authorized.
+
+
```
-### 5) ποΈ Database Setup (EF Core)
+`UAuthApp` can provide the built-in router, authentication state, and UltimateAuth client lifecycle integration for your component tree.
-After configuring UltimateAuth with Entity Framework Core, you need to create and apply database migrations.
+> Need full control over routing?
-5.1) Install EF Core tools (if not installed)
-```bash
-dotnet tool install --global dotnet-ef
+> UAuthApp also supports applications that provide their own Blazor Router. See the Blazor Routing guide for advanced routing configuration.
+
+### 5) Add the UltimateAuth Client Script
+Place this in `App.razor` or `index.html` in your Blazor client application:
+```html
+
```
-5.2) Add migration
-```bash
-dotnet ef migrations add InitUAuth
+
+### 6) Optional: Blazor Usings
+Add this in `_Imports.razor`:
+```csharp
+@using CodeBeam.UltimateAuth.Client.Blazor
```
-5.3) Update database
+### 7) Optional: Add Sample Data
+For the fastest way to try auth process, install the UltimateAuth sample seed package:
+
```bash
-dotnet ef database update
+dotnet add package CodeBeam.UltimateAuth.Sample.Seed
```
-π‘ Visual Studio (PMC alternative)
-If you are using Visual Studio, you can run these commands in Package Manager Console:
+Register the development seed:
+
```bash
-Add-Migration InitUAuth -Context UAuthDbContext
-Update-Database -Context UAuthDbContext
+builder.Services.AddUltimateAuthSampleSeed();
```
-β οΈ Notes
-- Migrations must be created in your application project, not in the UltimateAuth packages
-- You are responsible for managing migrations in production
-- Automatic database initialization is not enabled by default
-### 6) Optional: Blazor Usings
-Add this in `_Imports.razor`
+Then seed the application during development:
+
```csharp
-@using CodeBeam.UltimateAuth.Client.Blazor
+if (app.Environment.IsDevelopment())
+{
+ await app.SeedUltimateAuthAsync();
+}
```
-### β
Done
+The development seed includes ready-to-use accounts:
+
+| Identifier | Secret |
+|------------|----------|
+| `admin` | `admin` |
+| `user` | `user` |
+
+You can use these credentials to test the auth flows immediately.
+
+> Development only: Sample users and credentials are intended for evaluation and local development. Do not use them in production.
+
+### β
You're Ready
---
## π‘ Usage
-Inject IUAuthClient and simply call methods.
+**One Client. Your Auth Application API.**
+
+For most application-level authentication and identity operations, start with `IUAuthClient`.
+
+`IUAuthClient` provides a single entry point to UltimateAuth capabilities such as authentication flows, users, sessions, tokens, profiles, credentials, and authorization β without requiring your application code to manage the underlying authentication transport.
+
+> UltimateAuth treats authentication and identity as application services. Your application works with explicit operations and structured results while UltimateAuth handles the underlying authentication flow.
### Examples
Login
@@ -239,8 +255,8 @@ private async Task Login()
{
var request = new LoginRequest
{
- Identifier = "UAuthUser",
- Secret = "UAuthPassword",
+ Identifier = "admin",
+ Secret = "admin",
};
await UAuthClient.Flows.LoginAsync(request);
}
@@ -254,9 +270,9 @@ private async Task Register()
{
var request = new CreateUserRequest
{
- UserName = _username,
- Password = _password,
- Email = _email,
+ UserName = "NewUser",
+ Password = "NewUserPassword",
+ Email = "newuser@example.com",
};
var result = await UAuthClient.Users.CreateAsync(request);
@@ -282,11 +298,14 @@ private async Task LogoutOthersAsync()
}
```
-UltimateAuth turns Auth into a simple application service β not a separate system you fight against.
-- No manual token handling
-- No custom HTTP plumbing
-- No fragile redirect logic
-- All built-in with extensible options.
+With `IUAuthClient`, common application code doesn't need to manually orchestrate:
+- token handling
+- authentication HTTP calls
+- session operations
+- redirect plumbing
+- client-specific authentication flows
+
+Start with the simple API. Drop down to UltimateAuth's extensibility points when your application needs more control.
---
diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md
index 92afaeef..27f9e35c 100644
--- a/docs/content/getting-started/real-world-setup.md
+++ b/docs/content/getting-started/real-world-setup.md
@@ -18,8 +18,9 @@ In real applications, you will typically configure:
This guide shows how to set up UltimateAuth for real-world scenarios.
## ποΈ Using Entity Framework Core
+For production, you should use a persistent store. (In-memory provider is volatile and automatically resets on each restart.)
-For production, you should use a persistent store. In this setup, you no longer need the `CodeBeam.UltimateAuth.InMemory.Bundle` package.
+In this setup, you no longer need the `CodeBeam.UltimateAuth.InMemory.Bundle` package.
### Install Packages
@@ -34,26 +35,74 @@ builder.Services
.AddUltimateAuthEntityFrameworkCore(db =>
{
db.UseSqlite("Data Source=uauth.db");
- // or UseSqlServer / UseNpgsql
+ // or UseSqlServer(...) / UseNpgsql(...) / UseMySql(...) etc.
});
builder.Services
.AddUltimateAuthClientBlazor();
```
-### Create Database & Migrations
+### Database Migrations
+
+UltimateAuth integrates with Entity Framework Core, but database migrations belong to your application.
+
+UltimateAuth does not automatically create or apply migrations on your behalf. This keeps your database schema lifecycle under your control and allows migrations to follow the same deployment and review process as the rest of your application.
+
+After configuring the Entity Framework Core provider, create the initial migration and update the database using either the .NET CLI or Visual Studio Package Manager Console.
+
+#### Option A β .NET CLI
+
+If you use the .NET CLI:
+
+```bash
+dotnet ef migrations add InitUAuth --context UAuthDbContext
+dotnet ef database update --context UAuthDbContext
+```
+
+If the dotnet ef command is not available, install the EF Core CLI tool:
+
```bash
-dotnet ef migrations add InitUAuth
-dotnet ef database update
+dotnet tool install --global dotnet-ef
```
-or
-If you are using Visual Studio, you can run these commands in Package Manager Console*:
+Your project also needs the Entity Framework Core design package:
+
+```bash
+dotnet add package Microsoft.EntityFrameworkCore.Design
+```
+
+#### Option B β Visual Studio Package Manager Console
+
+If you use Visual Studio, you can perform the same operation from Tools β NuGet Package Manager β Package Manager Console:
+
```bash
Add-Migration InitUAuth -Context UAuthDbContext
Update-Database -Context UAuthDbContext
```
-*Needs `Microsoft.EntityFrameworkCore.Design` and `Microsoft.EntityFrameworkCore.Tools`
+
+For Package Manager Console tooling, make sure the required EF Core tooling package is available:
+
+```bash
+Install-Package Microsoft.EntityFrameworkCore.Tools
+```
+
+### Who Owns the Migrations?
+
+Your application does.
+
+This is intentional. UltimateAuth provides the authentication and identity model through its Entity Framework Core integration, while your application remains responsible for managing the resulting database schema.
+
+This means you can:
+
+- review migrations before applying them,
+- include UltimateAuth schema changes in your normal deployment process,
+- control when database changes are applied,
+- maintain migration history alongside your application,
+- use the database provider and deployment strategy appropriate for your environment.
+
+When upgrading UltimateAuth, review the release notes for persistence-related schema changes and create a new migration when required.
+
+> **Tip:** Treat UltimateAuth model changes like any other Entity Framework Core model change: upgrade the package, create a migration, review the generated migration, and apply it through your normal deployment process.
## Configure Services With Options
UltimateAuth provides rich options for server and client service registration.
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 867b1a17..f2964dbf 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-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",
+ "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-memory provider is volatile and automatically resets on each restart.)\u003C/p\u003E\n\u003Cp\u003EIn 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(...) / UseMySql(...) etc.\n });\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase Migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth integrates with Entity Framework Core, but database migrations belong to your application.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations on your behalf. This keeps your database schema lifecycle under your control and allows migrations to follow the same deployment and review process as the rest of your application.\u003C/p\u003E\n\u003Cp\u003EAfter configuring the Entity Framework Core provider, create the initial migration and update the database using either the .NET CLI or Visual Studio Package Manager Console.\u003C/p\u003E\n\u003Ch4 id=\u0022option-a.net-cli\u0022\u003EOption A \u2014 .NET CLI\u003C/h4\u003E\n\u003Cp\u003EIf you use the .NET CLI:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\ndotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIf the dotnet ef command is not available, install the EF Core CLI tool:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EYour project also needs the Entity Framework Core design package:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch4 id=\u0022option-b-visual-studio-package-manager-console\u0022\u003EOption B \u2014 Visual Studio Package Manager Console\u003C/h4\u003E\n\u003Cp\u003EIf you use Visual Studio, you can perform the same operation from Tools \u2192 NuGet Package Manager \u2192 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\u003EFor Package Manager Console tooling, make sure the required EF Core tooling package is available:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EInstall-Package Microsoft.EntityFrameworkCore.Tools\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022who-owns-the-migrations\u0022\u003EWho Owns the Migrations?\u003C/h3\u003E\n\u003Cp\u003EYour application does.\u003C/p\u003E\n\u003Cp\u003EThis is intentional. UltimateAuth provides the authentication and identity model through its Entity Framework Core integration, while your application remains responsible for managing the resulting database schema.\u003C/p\u003E\n\u003Cp\u003EThis means you can:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003Ereview migrations before applying them,\u003C/li\u003E\n\u003Cli\u003Einclude UltimateAuth schema changes in your normal deployment process,\u003C/li\u003E\n\u003Cli\u003Econtrol when database changes are applied,\u003C/li\u003E\n\u003Cli\u003Emaintain migration history alongside your application,\u003C/li\u003E\n\u003Cli\u003Euse the database provider and deployment strategy appropriate for your environment.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EWhen upgrading UltimateAuth, review the release notes for persistence-related schema changes and create a new migration when required.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003ETip:\u003C/strong\u003E Treat UltimateAuth model changes like any other Entity Framework Core model change: upgrade the package, create a migration, review the generated migration, and apply it through your normal deployment process.\u003C/p\u003E\n\u003C/blockquote\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",
@@ -19,8 +19,13 @@
"Level": 1
},
{
- "Id": "create-database-migrations",
- "Text": "Create Database \u0026 Migrations",
+ "Id": "database-migrations",
+ "Text": "Database Migrations",
+ "Level": 1
+ },
+ {
+ "Id": "who-owns-the-migrations",
+ "Text": "Who Owns the Migrations?",
"Level": 1
},
{
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj
index f6e72309..3bf1515b 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj
@@ -1,4 +1,4 @@
-
+ο»Ώ
net10.0
@@ -9,19 +9,19 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor
index ac680f3b..8eec2be6 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor
@@ -1,9 +1,9 @@
-ο»Ώ@inherits UAuthHubLayoutComponentBase
+ο»Ώ@inherits UAuthHubLayoutBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
@inject NavigationManager Nav
-@if (!IsHubAuthorized)
+@if (!IsHubActive)
{
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs
index e5886028..4b196823 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs
@@ -52,7 +52,7 @@ private void HandleSignInClick()
if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase))
{
- Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true);
+ Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true);
return;
}
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
index 0cc81888..7897aca7 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
@@ -2,6 +2,7 @@
using CodeBeam.UltimateAuth.Client.Blazor;
using CodeBeam.UltimateAuth.Client.Runtime;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Stores;
using MudBlazor;
@@ -56,7 +57,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (HubSessionId.TryParse(HubKey, out var hubSessionId))
{
- await ReloadState();
+ await ReloadStateAsync();
}
await _loginForm.ReloadAsync();
@@ -140,20 +141,20 @@ private async Task ResolveReturnUrlAsync()
if (!string.IsNullOrWhiteSpace(fromContext))
return fromContext;
- var uri = Nav.ToAbsoluteUri(Nav.Uri);
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
- if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru))
+ if (query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) && !string.IsNullOrWhiteSpace(ru))
return ru!;
- if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey))
+ if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubKey) && !string.IsNullOrWhiteSpace(hubKey))
{
var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!));
if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl))
return flow.ReturnUrl!;
}
- return Nav.Uri;
+ return Navigation.Uri;
}
private async void StartCountdown()
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj
index a5ccdc5e..5e9e2eee 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj
@@ -9,10 +9,10 @@
-
-
-
-
+
+
+
+
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor
index ac680f3b..8eec2be6 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor
@@ -1,9 +1,9 @@
-ο»Ώ@inherits UAuthHubLayoutComponentBase
+ο»Ώ@inherits UAuthHubLayoutBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
@inject NavigationManager Nav
-@if (!IsHubAuthorized)
+@if (!IsHubActive)
{
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
index 7242adbd..4b35a909 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs
@@ -52,7 +52,7 @@ private void HandleSignInClick()
if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase))
{
- Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true);
+ Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true);
return;
}
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
index c3258e6a..a5f93cee 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
@@ -2,6 +2,7 @@
using CodeBeam.UltimateAuth.Client.Blazor;
using CodeBeam.UltimateAuth.Client.Runtime;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Stores;
using MudBlazor;
@@ -56,7 +57,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
if (HubSessionId.TryParse(HubKey, out var hubSessionId))
{
- await ReloadState();
+ await ReloadStateAsync();
}
await _loginForm.ReloadAsync();
@@ -140,20 +141,20 @@ private async Task ResolveReturnUrlAsync()
if (!string.IsNullOrWhiteSpace(fromContext))
return fromContext;
- var uri = Nav.ToAbsoluteUri(Nav.Uri);
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
- if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru))
+ if (query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) && !string.IsNullOrWhiteSpace(ru))
return ru!;
- if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey))
+ if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubKey) && !string.IsNullOrWhiteSpace(hubKey))
{
var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!));
if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl))
return flow.ReturnUrl!;
}
- return Nav.Uri;
+ return Navigation.Uri;
}
private async void StartCountdown()
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj
index 90bfe9d6..98b4e775 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj
@@ -9,19 +9,19 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
+
+
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor
index 7218a9c1..a5e4fde7 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/authorized-test"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor
index 74cb1b79..162c37ea 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/home"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject UAuthClientDiagnostics Diagnostics
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs
index ab0018b8..5764a674 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs
@@ -12,7 +12,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages;
-public partial class Home : UAuthFlowPageBase
+public partial class Home : UAuthPageBase
{
private string _selectedAuthState = "UAuthState";
private ClaimsPrincipal? _aspNetCoreState;
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor
index f1d587c7..5eec659a 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "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/login"
@attribute [UAuthLoginPage]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
@@ -34,7 +34,7 @@
-
+
Continue
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
index 0559a29b..011bdec3 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
@@ -7,7 +7,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages;
-public partial class Login : UAuthFlowPageBase
+public partial class Login : UAuthPageBase
{
private string? _username;
private string? _password;
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor
index 881cae5c..20b38b75 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/register"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor
index 753878b8..2623395b 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/reset"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
index 71a4d93e..2cd5e204 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
@@ -37,7 +37,7 @@ private async Task ResetPasswordAsync()
if (result.IsSuccess)
{
Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success);
- Nav.NavigateTo("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/login");
+ Navigation.NavigateTo("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/login");
}
else
{
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm
index f64b9c90..1c694283 100644
Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal
index 05f8b783..b5380fc8 100644
Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj
index daf780dd..5345f4b9 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj
@@ -8,10 +8,10 @@
-
-
-
-
+
+
+
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs
index 47d68df7..92d16814 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs
@@ -52,7 +52,7 @@ private void HandleSignInClick()
if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase))
{
- Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true);
+ Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true);
return;
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor
index d0a06c06..0b3772f8 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor
@@ -1,6 +1,7 @@
ο»Ώ@page "/authorized-test"
+@using CodeBeam.UltimateAuth.Core.Defaults
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@@ -19,6 +20,10 @@
+
+ This is admin view content.
+
+
UltimateAuth protects this resource based on your session and permissions.
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
index 02cb0f28..0e90fd7a 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/home"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject UAuthClientDiagnostics Diagnostics
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
index 3faeca19..415e586a 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs
@@ -12,7 +12,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages;
-public partial class Home : UAuthFlowPageBase
+public partial class Home : UAuthPageBase
{
private string _selectedAuthState = "UAuthState";
private ClaimsPrincipal? _aspNetCoreState;
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
index f1d587c7..5eec659a 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "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/login"
@attribute [UAuthLoginPage]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
@@ -34,7 +34,7 @@
-
+
Continue
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
index 0cbc8441..d9f06ffa 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
@@ -7,7 +7,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages;
-public partial class Login : UAuthFlowPageBase
+public partial class Login : UAuthPageBase
{
private string? _username;
private string? _password;
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor
index 881cae5c..20b38b75 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/register"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor
index 753878b8..2623395b 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/reset"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
index 9bcaf5f7..681ff884 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
@@ -37,7 +37,7 @@ private async Task ResetPasswordAsync()
if (result.IsSuccess)
{
Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success);
- Nav.NavigateTo("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/login");
+ Navigation.NavigateTo("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/login");
}
else
{
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj
index 4b88fd56..db5ea39b 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj
@@ -8,12 +8,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs
index 8567fb06..06946b76 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs
@@ -52,7 +52,7 @@ private void HandleSignInClick()
if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase))
{
- Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true);
+ Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true);
return;
}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor
index 2dd294b2..cf9519f6 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/authorized-test"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
index 4db6dfcf..8b20171f 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/home"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject UAuthClientDiagnostics Diagnostics
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
index 6c8122d8..4464b6ff 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs
@@ -12,7 +12,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages;
-public partial class Home : UAuthFlowPageBase
+public partial class Home : UAuthPageBase
{
private string _selectedAuthState = "UAuthState";
private ClaimsPrincipal? _aspNetCoreState;
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
index 5c3245d9..595addf8 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "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/login"
@attribute [UAuthLoginPage]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
@@ -34,7 +34,7 @@
-
+
Continue
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
index b644ee9a..78129102 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
@@ -8,7 +8,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages;
-public partial class Login : UAuthFlowPageBase
+public partial class Login : UAuthPageBase
{
private string? _username;
private string? _password;
@@ -83,7 +83,7 @@ private async Task StartPkceLogin()
{
string? returnUrl = null;
if (!string.IsNullOrEmpty(ReturnUrl))
- returnUrl = Nav.BaseUri + ReturnUrl.TrimStart('/');
+ returnUrl = Navigation.BaseUri + ReturnUrl.TrimStart('/');
await UAuthClient.Flows.BeginPkceAsync(returnUrl);
}
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor
index 881cae5c..20b38b75 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/register"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor
index 753878b8..2623395b 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/reset"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
index b76e12b1..43e7b541 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
@@ -37,7 +37,7 @@ private async Task ResetPasswordAsync()
if (result.IsSuccess)
{
Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success);
- Nav.NavigateTo("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/login");
+ Navigation.NavigateTo("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/login");
}
else
{
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj
index 93156414..e4408ae2 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj
@@ -11,10 +11,10 @@
-
-
-
-
+
+
+
+
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs
index dfefa793..1e788a96 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs
@@ -52,7 +52,7 @@ private void HandleSignInClick()
if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase))
{
- Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true);
+ Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true);
return;
}
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor
index 9d78b597..962555ed 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/authorized-test"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor
index b2aba719..2503b3af 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/home"
@attribute [UAuthAuthorize]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject UAuthClientDiagnostics Diagnostics
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs
index c296ed00..f14b1df3 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs
@@ -12,7 +12,7 @@
namespace CodeBeam.UAuth.Sample.IntWasm.Client.Pages;
-public partial class Home : UAuthFlowPageBase
+public partial class Home : UAuthPageBase
{
private string _selectedAuthState = "UAuthState";
private ClaimsPrincipal? _aspNetCoreState;
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor
index 7da73973..43e409a7 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor
@@ -1,7 +1,7 @@
ο»Ώ@page "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/login"
@using CodeBeam.UltimateAuth.Client.Runtime
@attribute [UAuthLoginPage]
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
@@ -35,7 +35,7 @@
-
+
Continue
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
index 970f9128..2cb3f032 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
@@ -8,7 +8,7 @@
namespace CodeBeam.UAuth.Sample.IntWasm.Client.Pages;
-public partial class Login : UAuthFlowPageBase
+public partial class Login : UAuthPageBase
{
private string? _username;
private string? _password;
@@ -83,7 +83,7 @@ private async Task StartPkceLogin()
{
string? returnUrl = null;
if (!string.IsNullOrEmpty(ReturnUrl))
- returnUrl = Nav.BaseUri + ReturnUrl.TrimStart('/');
+ returnUrl = Navigation.BaseUri + ReturnUrl.TrimStart('/');
await UAuthClient.Flows.BeginPkceAsync(returnUrl);
}
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor
index bb174660..41c7e05b 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor
@@ -1,6 +1,6 @@
ο»Ώ@page "/register"
@using CodeBeam.UltimateAuth.Client.Runtime
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@implements IDisposable
@inject IUAuthClient UAuthClient
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor
index 753878b8..2623395b 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor
@@ -1,5 +1,5 @@
ο»Ώ@page "/reset"
-@inherits UAuthFlowPageBase
+@inherits UAuthPageBase
@inject IUAuthClient UAuthClient
@inject ISnackbar Snackbar
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
index fc9942f0..be79c63a 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
@@ -37,7 +37,7 @@ private async Task ResetPasswordAsync()
if (result.IsSuccess)
{
Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success);
- Nav.NavigateTo("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/login");
+ Navigation.NavigateTo("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/login");
}
else
{
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj
index f309e0b0..e03668c9 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj
index 2d474a92..20471edc 100644
--- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj
@@ -1,4 +1,4 @@
-
+ο»Ώ
net10.0
@@ -7,7 +7,7 @@
-
+
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj
index 1c868dd7..cde66c01 100644
--- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj
+++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs
similarity index 100%
rename from src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs
rename to src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs
new file mode 100644
index 00000000..8711835c
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs
@@ -0,0 +1,8 @@
+ο»Ώnamespace CodeBeam.UltimateAuth.Core.Contracts;
+
+public enum AuthorizationMatchMode
+{
+ Any = 0,
+ All = 1,
+ Category = 2
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
index 7c057ff2..e004fb60 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs
@@ -10,8 +10,8 @@ public sealed record PkceCompleteRequest
[JsonPropertyName("code_verifier")]
public required string CodeVerifier { get; init; }
-
public required string Identifier { get; init; }
+
public required string Secret { get; init; }
[JsonPropertyName("return_url")]
diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
index 37881b9d..31de832e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
@@ -36,8 +36,11 @@ public static class Form
public static class Query
{
- public const string ReturnUrl = "return_url";
- public const string Hub = "hub";
+ public const string Payload = "uauth";
+ public const string Focus = "uauth_focus";
+ public const string ReturnUrl = "uauth_return_url";
+ public const string Identifier = "uauth_identifier";
+ public const string Hub = "uauth_hub";
}
public static class Headers
diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
index ed166fcc..d3c09386 100644
--- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
+++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs
@@ -1,3 +1,4 @@
ο»Ώusing System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
index dd7b1e36..cbaaf154 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
@@ -19,17 +19,15 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler
private readonly IUAuthFlowService _flow;
private readonly IAccessContextFactory _accessContextFactory;
private readonly ISessionApplicationService _sessionApplicationService;
- private readonly IClock _clock;
private readonly IUAuthCookieManager _cookieManager;
private readonly IAuthRedirectResolver _redirectResolver;
- public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver)
+ public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver)
{
_authContext = authContext;
_flow = flow;
_accessContextFactory = accessContextFactory;
_sessionApplicationService = sessionApplicationService;
- _clock = clock;
_cookieManager = cookieManager;
_redirectResolver = redirectResolver;
}
@@ -151,7 +149,7 @@ public async Task LogoutAllSelfAsync(HttpContext ctx)
flow,
UAuthActions.Flows.LogoutAllSelf,
resource: "flows",
- resourceId: userKey);
+ resourceId: userKey.Value);
await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted);
return Results.Ok();
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
index 9743e39e..0270c215 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
@@ -163,6 +163,11 @@ public async Task CompleteAsync(HttpContext ctx)
if (request is null)
return Results.BadRequest("Invalid PKCE payload.");
+ if (string.IsNullOrWhiteSpace(request.AuthorizationCode) || string.IsNullOrWhiteSpace(request.CodeVerifier))
+ {
+ return Results.BadRequest("authorization_code and code_verifier are required.");
+ }
+
var result = await _pkceService.CompleteAsync(
auth,
new PkceCompleteRequest
@@ -269,7 +274,7 @@ public async Task CompleteAsync(HttpContext ctx)
var codeVerifier = form?["code_verifier"].FirstOrDefault();
var identifier = form?["Identifier"].FirstOrDefault();
var secret = form?["Secret"].FirstOrDefault();
- var returnUrl = form?["return_url"].FirstOrDefault();
+ var returnUrl = form?[UAuthConstants.Form.ReturnUrl].FirstOrDefault();
if (string.IsNullOrWhiteSpace(authorizationCode))
throw new UAuthValidationException("authorization_code is required");
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
index f95fd6fa..b0875a33 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
@@ -82,7 +82,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
);
}
- var snapshot = await _snapshotFactory.CreateAsync(result);
+ var snapshot = await _snapshotFactory.CreateAsync(result, ct);
return Results.Ok(new AuthValidationResult
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
index 54c018d5..1b353d97 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
@@ -131,8 +131,15 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
{
var chain = await sessionStore.GetChainByDeviceAsync(userKey.Value, deviceId, ct);
- if (chain is not null && !chain.IsRevoked)
- chainId = chain.ChainId;
+ if (chain is not null)
+ {
+ var chainState = chain.GetState(now, _options.Session.IdleTimeout);
+
+ if (chainState == SessionState.Active)
+ {
+ chainId = chain.ChainId;
+ }
+ }
}
// TODO: Add accountState here, currently it only checks factor state
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs
deleted file mode 100644
index 8108d0bb..00000000
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-ο»Ώusing CodeBeam.UltimateAuth.Core;
-
-namespace CodeBeam.UltimateAuth.Server.Flows;
-
-///
-/// Resolves refresh behavior based on AuthMode.
-/// This class is the single source of truth for refresh capability.
-///
-public static class RefreshDecisionResolver
-{
- public static RefreshDecision Resolve(UAuthMode mode)
- {
- return mode switch
- {
- UAuthMode.PureOpaque => RefreshDecision.SessionTouch,
-
- UAuthMode.Hybrid
- or UAuthMode.SemiHybrid
- or UAuthMode.PureJwt => RefreshDecision.TokenRotation,
-
- _ => RefreshDecision.NotSupported
- };
- }
-}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs
index 9af186da..ec1ba774 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs
@@ -25,7 +25,7 @@ internal static async Task HandleHubEntry(HttpContext ctx, IAuthStore s
var authorizationCode = form["authorization_code"].ToString();
var codeVerifier = form["code_verifier"].ToString();
var deviceId = form["device_id"].ToString();
- var returnUrl = form["return_url"].ToString();
+ var returnUrl = form[UAuthConstants.Form.ReturnUrl].ToString();
if (!Enum.TryParse(form["__uauth_client_profile"], ignoreCase: true, out var clientProfile))
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
index 7c0c35e0..0fb5e56c 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs
@@ -92,8 +92,13 @@ await kernel.ExecuteAsync(async _ =>
//chain = await kernel.GetChainAsync(context.ChainId.Value)
// ?? throw new UAuthNotFoundException("Chain not found.");
- if (chain.IsRevoked)
- throw new UAuthValidationException("Chain revoked.");
+ var chainState = chain.GetState(now, _options.Session.IdleTimeout);
+
+ if (chainState != SessionState.Active)
+ throw new UAuthValidationException("Chain is not active.");
+
+ //if (chain.IsRevoked)
+ // throw new UAuthValidationException("Chain revoked.");
if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant)
throw new UAuthValidationException("Invalid chain ownership.");
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs
index cbee309e..294ce070 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs
@@ -5,7 +5,7 @@ public sealed class RedirectDecision
public bool Enabled { get; }
public string? TargetUrl { get; }
- private RedirectDecision(bool enabled, string? targetUrl)
+ internal RedirectDecision(bool enabled, string? targetUrl)
{
Enabled = enabled;
TargetUrl = targetUrl;
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
index 92f0e6d6..ad04b6bf 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
@@ -13,14 +13,12 @@ public sealed class RefreshTokenRotationService : IRefreshTokenRotationService
private readonly IRefreshTokenValidator _validator;
private readonly IRefreshTokenStoreFactory _storeFactory;
private readonly ITokenIssuer _tokenIssuer;
- private readonly IClock _clock;
- public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock)
+ public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer)
{
_validator = validator;
_storeFactory = storeFactory;
_tokenIssuer = tokenIssuer;
- _clock = clock;
}
// TODO: Handle reuse detection and make flow knows situation, but don't make security branch.
@@ -37,25 +35,27 @@ public async Task RotateAsync(AuthFlowContext flo
},
ct);
- if (!validation.IsValid)
- return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() };
-
- var store = _storeFactory.Create(validation.Tenant);
-
if (validation.IsReuseDetected)
{
+ var store1 = _storeFactory.Create(validation.Tenant);
+
if (validation.ChainId is not null)
{
- await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct);
+ await store1.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct);
}
else if (validation.SessionId is not null)
{
- await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct);
+ await store1.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct);
}
return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() };
}
+ if (!validation.IsValid)
+ return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() };
+
+ var store = _storeFactory.Create(validation.Tenant);
+
if (validation.UserKey is not UserKey userKey)
throw new UAuthValidationException("Validated refresh token does not contain a UserKey.");
@@ -85,7 +85,8 @@ public async Task RotateAsync(AuthFlowContext flo
Result = RefreshTokenRotationResult.Failed()
};
- // Never issue new refresh token before revoke old. Upperline doesn't persist token currently.
+ // Generate the replacement token without persisting it.
+ // Revoke the current token and persist its replacement atomically.
await store.ExecuteAsync(async ct2 =>
{
await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2);
@@ -97,7 +98,7 @@ await store.ExecuteAsync(async ct2 =>
userKey: userKey,
sessionId: sessionId,
chainId: validation.ChainId,
- createdAt: _clock.UtcNow,
+ createdAt: context.Now,
expiresAt: refreshToken.ExpiresAt
);
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
index d7640693..27f42a3a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
@@ -33,12 +33,12 @@ public async Task> GetUserChainsAsync(AccessCon
chains = request.SortBy switch
{
nameof(SessionChainSummary.ChainId) => request.Descending
- ? chains.OrderByDescending(x => x.ChainId).ToList()
- : chains.OrderBy(x => x.Version).ToList(),
+ ? chains.OrderByDescending(x => x.ChainId.Value).ToList()
+ : chains.OrderBy(x => x.ChainId.Value).ToList(),
nameof(SessionChainSummary.CreatedAt) => request.Descending
? chains.OrderByDescending(x => x.CreatedAt).ToList()
- : chains.OrderBy(x => x.Version).ToList(),
+ : chains.OrderBy(x => x.CreatedAt).ToList(),
nameof(SessionChainSummary.LastSeenAt) => request.Descending
? chains.OrderByDescending(x => x.LastSeenAt).ToList()
@@ -179,6 +179,12 @@ public async Task RevokeUserChainAsync(AccessContext context, User
var isCurrent = context.ActorChainId == chainId;
var store = _storeFactory.Create(context.ResourceTenant);
+ var chain = await store.GetChainAsync(chainId, innerCt)
+ ?? throw new UAuthNotFoundException("chain_not_found");
+
+ if (chain.UserKey != userKey)
+ throw new UAuthValidationException("User conflict.");
+
await store.ExecuteAsync(async innerCt2 => {
await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow);
});
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs
index 2e35a6cb..698eb13c 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs
@@ -9,9 +9,7 @@ public sealed class UAuthSessionQueryService : ISessionQueryService
private readonly ISessionStoreFactory _storeFactory;
private readonly IAuthFlowContextAccessor _authFlow;
- public UAuthSessionQueryService(
- ISessionStoreFactory storeFactory,
- IAuthFlowContextAccessor authFlow)
+ public UAuthSessionQueryService(ISessionStoreFactory storeFactory, IAuthFlowContextAccessor authFlow)
{
_storeFactory = storeFactory;
_authFlow = authFlow;
@@ -19,22 +17,22 @@ public UAuthSessionQueryService(
public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default)
{
- return CreateKernel().GetSessionAsync(sessionId);
+ return CreateKernel().GetSessionAsync(sessionId, ct);
}
public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default)
{
- return CreateKernel().GetSessionsByChainAsync(chainId);
+ return CreateKernel().GetSessionsByChainAsync(chainId, ct);
}
public Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default)
{
- return CreateKernel().GetChainsByUserAsync(userKey);
+ return CreateKernel().GetChainsByUserAsync(userKey, ct: ct);
}
public Task ResolveChainIdAsync(AuthSessionId sessionId, CancellationToken ct = default)
{
- return CreateKernel().GetChainIdBySessionAsync(sessionId);
+ return CreateKernel().GetChainIdBySessionAsync(sessionId, ct);
}
private ISessionStore CreateKernel()
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
index 787e5281..40aab932 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
@@ -27,7 +27,7 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi
public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default)
{
var kernel = _storeFactory.Create(context.Tenant);
- var session = await kernel.GetSessionAsync(context.SessionId);
+ var session = await kernel.GetSessionAsync(context.SessionId, ct);
if (session is null)
return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId);
@@ -36,7 +36,7 @@ public async Task ValidateSessionAsync(SessionValidatio
if (state != SessionState.Active)
return SessionValidationResult.Invalid(state, session.UserKey, session.SessionId, session.ChainId);
- var chain = await kernel.GetChainAsync(session.ChainId);
+ var chain = await kernel.GetChainAsync(session.ChainId, ct);
if (chain is null || chain.IsRevoked)
return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId);
@@ -53,7 +53,7 @@ public async Task ValidateSessionAsync(SessionValidatio
if (chain.Tenant != context.Tenant)
return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId);
- var root = await kernel.GetRootByUserAsync(session.UserKey);
+ var root = await kernel.GetRootByUserAsync(session.UserKey, ct);
if (root is null || root.IsRevoked)
return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId);
diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs
index d7c52993..98f4ee94 100644
--- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs
+++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs
@@ -38,6 +38,21 @@ public EfCoreAuthenticationSecurityStateStore(TDbContext db, TenantExecutionCont
public async Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
+ if (state.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
+ var exists = await DbSet.AnyAsync(x =>
+ x.Tenant == _tenant &&
+ x.UserKey == state.UserKey &&
+ x.Scope == state.Scope &&
+ x.CredentialType == state.CredentialType,
+ ct);
+
+ if (exists)
+ throw new UAuthConflictException("security_state_already_exists");
+
var entity = AuthenticationSecurityStateMapper.ToProjection(state);
DbSet.Add(entity);
@@ -47,6 +62,11 @@ public async Task AddAsync(AuthenticationSecurityState state, CancellationToken
public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
+ if (state.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
var entity = await DbSet
.SingleOrDefaultAsync(x =>
x.Tenant == _tenant &&
diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs
index 503ed884..219b9a53 100644
--- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs
+++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs
@@ -70,11 +70,14 @@ public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion,
var key = (state.UserKey, state.Scope, state.CredentialType);
- if (!_index.TryGetValue(key, out var id) || id != state.Id)
+ if (!_index.TryGetValue(key, out var id))
+ throw new UAuthNotFoundException("security_state_not_found");
+
+ if (id != state.Id)
throw new UAuthConflictException("security_state_index_corrupted");
if (!_byId.TryGetValue(state.Id, out var current))
- throw new UAuthNotFoundException("security_state_not_found");
+ throw new UAuthConflictException("security_state_index_corrupted");
if (current.SecurityVersion != expectedVersion)
throw new UAuthConflictException("security_state_version_conflict");
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
index b6f8d893..9412811b 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
@@ -34,8 +34,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default)
var exists = await DbSetRole
.AnyAsync(x =>
x.Tenant == _tenant &&
- x.NormalizedName == role.NormalizedName &&
- x.DeletedAt == null,
+ x.NormalizedName == role.NormalizedName,
ct);
if (exists)
@@ -186,7 +185,8 @@ public async Task> GetByIdsAsync(
.AsNoTracking()
.Where(x =>
x.Tenant == _tenant &&
- roleIds.Contains(x.Id))
+ roleIds.Contains(x.Id) &&
+ x.DeletedAt == null)
.ToListAsync(ct);
var roleIdsSet = entities.Select(x => x.Id).ToList();
@@ -214,6 +214,8 @@ public async Task> GetByIdsAsync(
return result.AsReadOnly();
}
+
+ // TODO: Add UltimateAuth standard: tiebreaker for sorting fields that are not unique.
public async Task> QueryAsync(RoleQuery query, CancellationToken ct = default)
{
var normalized = query.Normalize();
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs
new file mode 100644
index 00000000..ed166fcc
--- /dev/null
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs
@@ -0,0 +1,3 @@
+ο»Ώusing System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
index 11bde034..5dc2e15e 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
@@ -17,8 +17,7 @@ public InMemoryRoleStore(TenantExecutionContext tenant) : base(tenant)
protected override void BeforeAdd(Role entity)
{
if (TenantValues().Any(r =>
- r.NormalizedName == entity.NormalizedName &&
- !r.IsDeleted))
+ r.NormalizedName == entity.NormalizedName))
{
throw new UAuthConflictException("role_already_exists");
}
@@ -30,8 +29,7 @@ protected override void BeforeSave(Role entity, Role current, long expectedVersi
{
if (TenantValues().Any(r =>
r.NormalizedName == entity.NormalizedName &&
- r.Id != entity.Id &&
- !r.IsDeleted))
+ r.Id != entity.Id))
{
throw new UAuthConflictException("role_name_already_exists");
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs
new file mode 100644
index 00000000..ed166fcc
--- /dev/null
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs
@@ -0,0 +1,3 @@
+ο»Ώusing System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
index ae13fda7..08ba606f 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
@@ -136,7 +136,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx)
if (!flow.IsAuthenticated)
return Results.Unauthorized();
- var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
var accessContext = await _accessContextFactory.CreateAsync(
flow,
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
index 11b3d28b..9a3051d1 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
@@ -94,7 +94,32 @@ public async Task> GetRolesAsync(AccessContext context
var total = joined.Count;
- var pageItems = joined.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList();
+ IEnumerable ordered = request.SortBy switch
+ {
+ nameof(UserRoleInfo.Name) =>
+ request.Descending
+ ? joined
+ .OrderByDescending(x => x.Name)
+ .ThenBy(x => x.RoleId.Value)
+ : joined
+ .OrderBy(x => x.Name)
+ .ThenBy(x => x.RoleId.Value),
+
+ nameof(UserRoleInfo.AssignedAt) =>
+ request.Descending
+ ? joined
+ .OrderByDescending(x => x.AssignedAt)
+ .ThenBy(x => x.RoleId.Value)
+ : joined
+ .OrderBy(x => x.AssignedAt)
+ .ThenBy(x => x.RoleId.Value),
+
+ _ => joined
+ .OrderBy(x => x.Name)
+ .ThenBy(x => x.RoleId.Value)
+ };
+
+ var pageItems = ordered.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList();
return new PagedResult(
pageItems,
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs
index 971364ac..ae253156 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs
@@ -1,8 +1,37 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Blazor;
+///
+/// Declares UltimateAuth authorization requirements for a Blazor component or page.
+///
+///
+///
+/// The attribute can be used to associate role and permission requirements with routable or authorization-aware Blazor components.
+///
+///
+/// Role and permission values are expressed as comma-separated lists. The effective
+/// authorization behavior is determined by the UltimateAuth authorization pipeline that consumes this metadata.
+///
+///
+/// This attribute describes authorization requirements only. It does not perform authorization by itself.
+///
+///
[AttributeUsage(AttributeTargets.Class)]
public sealed class UAuthAuthorizeAttribute : Attribute
{
+ ///
+ /// Gets or sets the comma-separated roles associated with the authorization requirement.
+ ///
+ ///
+ /// A , empty, or whitespace value indicates that no explicit role requirement is declared by this property.
+ ///
public string? Roles { get; set; }
+
+ ///
+ /// Gets or sets the comma-separated UltimateAuth permissions associated with the authorization requirement.
+ ///
+ ///
+ /// A , empty, or whitespace value indicates that no explicit
+ /// permission requirement is declared by this property.
+ ///
public string? Permissions { get; set; }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj
index 02509da9..73d5c800 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj
@@ -2,7 +2,6 @@
net8.0;net9.0;net10.0
- $(NoWarn);1591
CodeBeam.UltimateAuth.Client.Blazor
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs
similarity index 59%
rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs
rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs
index 38d8ade6..d2462d35 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs
@@ -2,28 +2,41 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
-public abstract class UAuthReactiveComponentBase : ComponentBase, IDisposable
+///
+/// Base class for Blazor components that participate in UltimateAuth authentication state and authorization lifecycle.
+///
+public abstract class UAuthComponentBase : ComponentBase, IDisposable
{
private UAuthState? _previousState;
private bool _rendered;
+ ///
+ /// Gets the current UltimateAuth authentication state supplied by UAuthApp.
+ ///
[CascadingParameter]
protected UAuthState AuthState { get; set; } = default!;
- [Inject] protected NavigationManager Nav { get; set; } = default!;
+ ///
+ /// Gets the Blazor navigation service.
+ ///
+ [Inject] protected NavigationManager Navigation { get; set; } = default!;
///
- /// Automatically re-render when UAuthState changes.
- /// Can be overridden to disable.
+ /// Automatically re-render when UAuthState changes. Can be overridden to disable.
///
protected virtual bool AutoRefreshOnAuthStateChanged => true;
+ ///
+ /// Called when the component's parameters have been set. This method ensures that the component is properly registered
+ /// with the current and evaluates authorization requirements.
+ ///
+ ///
protected override void OnParametersSet()
{
base.OnParametersSet();
if (AuthState is null)
- throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(AuthState)}. " +
+ throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(UAuthState)}. " +
$"Make sure it is used inside .");
if (!ReferenceEquals(_previousState, AuthState))
@@ -38,12 +51,20 @@ protected override void OnParametersSet()
EvaluateAuthorization();
}
+ ///
+ /// Called after the component has been rendered. This method sets the _rendered flag to true on the first render.
+ ///
+ ///
+ ///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
+ {
_rendered = true;
+ // Never call EvaluateAuthorization() here, because it breaks UAuthAuthorize attribute behavior.
+ }
}
private void OnAuthStateChanged(UAuthStateChangeReason reason)
@@ -101,16 +122,25 @@ private void EvaluateAuthorization()
}
}
+ ///
+ /// Called when the component requires authentication but the current user is not authenticated.
+ ///
protected virtual void OnUnauthorized()
{
- Nav.NavigateTo("/");
+ Navigation.NavigateTo("/");
}
+ ///
+ /// Called when the current user is authenticated but does not satisfy the authorization requirements of the component.
+ ///
protected virtual void OnForbidden()
{
- Nav.NavigateTo("/forbidden");
+ Navigation.NavigateTo("/forbidden");
}
+ ///
+ /// Disposes of the component and unsubscribes from the event to prevent memory leaks.
+ ///
public virtual void Dispose()
{
if (_previousState is not null)
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs
deleted file mode 100644
index c1305221..00000000
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts;
-using Microsoft.AspNetCore.WebUtilities;
-using System.Text;
-using System.Text.Json;
-
-namespace CodeBeam.UltimateAuth.Client.Blazor;
-
-public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase
-{
- protected AuthFlowPayload? UAuthPayload { get; private set; }
- protected string? ReturnUrl { get; private set; }
- protected bool ShouldFocus { get; private set; }
- protected string? Identifier { get; private set; }
-
-
- protected virtual bool ClearQueryAfterParse => true;
-
- private bool _needsClear;
- private string? _lastParsedUri;
- private bool _payloadConsumed;
-
- protected override void OnParametersSet()
- {
- base.OnParametersSet();
-
- var currentUri = Nav.Uri;
-
- if (string.Equals(_lastParsedUri, currentUri, StringComparison.Ordinal))
- return;
-
- _lastParsedUri = currentUri;
-
- _payloadConsumed = false;
-
- var uri = Nav.ToAbsoluteUri(currentUri);
- var query = QueryHelpers.ParseQuery(uri.Query);
-
- ShouldFocus = query.TryGetValue("focus", out var focus) && focus == "1";
- ReturnUrl = query.TryGetValue("returnUrl", out var ru) ? ru.ToString() : null;
- Identifier = query.TryGetValue("identifier", out var id) ? id.ToString() : null;
-
- UAuthPayload = null;
-
- if (query.TryGetValue("uauth", out var raw) && !string.IsNullOrWhiteSpace(raw))
- {
- try
- {
- var bytes = WebEncoders.Base64UrlDecode(raw!);
- var json = Encoding.UTF8.GetString(bytes);
- UAuthPayload = JsonSerializer.Deserialize(json);
- }
- catch
- {
- UAuthPayload = null;
- }
- }
-
- _needsClear = ClearQueryAfterParse && uri.Query.Length > 1;
- }
-
- protected override async Task OnAfterRenderAsync(bool firstRender)
- {
- await base.OnAfterRenderAsync(firstRender);
-
- if (TryConsumePayload(out var payload))
- await OnUAuthPayloadAsync(payload!);
-
- if (ConsumeFocus())
- await OnFocusRequestedAsync();
-
- if (_needsClear)
- {
- _needsClear = false;
- var clean = new Uri(Nav.Uri).GetLeftPart(UriPartial.Path);
- Nav.NavigateTo(clean, replace: true);
- }
- }
-
- protected bool ConsumeFocus()
- {
- if (!ShouldFocus)
- return false;
-
- ShouldFocus = false;
- return true;
- }
-
- protected bool TryConsumePayload(out AuthFlowPayload? payload)
- {
- if (_payloadConsumed || UAuthPayload is null)
- {
- payload = null;
- return false;
- }
-
- _payloadConsumed = true;
- payload = UAuthPayload;
- return true;
- }
-
- protected virtual Task OnUAuthPayloadAsync(AuthFlowPayload payload) => Task.CompletedTask;
- protected virtual Task OnFocusRequestedAsync() => Task.CompletedTask;
-}
\ No newline at end of file
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs
new file mode 100644
index 00000000..086dd486
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs
@@ -0,0 +1,117 @@
+ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using Microsoft.AspNetCore.Components;
+
+namespace CodeBeam.UltimateAuth.Client.Blazor;
+
+///
+/// Base class for Blazor layouts that participate in an UltimateAuth Hub flow.
+///
+///
+///
+/// The layout resolves the Hub session identifier from the current navigation URI
+/// and exposes the corresponding to derived layouts.
+///
+///
+/// Hub state is obtained through . An absent or invalid
+/// Hub session identifier results in no current Hub state.
+///
+///
+/// This type provides Hub flow state to the UI and does not itself authorize,
+/// complete, or otherwise make security decisions for the authentication flow.
+///
+///
+public abstract class UAuthHubLayoutBase : LayoutComponentBase
+{
+ ///
+ /// Gets the navigation service used to inspect the current URI.
+ ///
+ [Inject] protected NavigationManager Navigation { get; set; } = default!;
+
+ ///
+ /// Gets the Hub flow reader used to retrieve the state of the current Hub session.
+ ///
+ [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!;
+
+ ///
+ /// Gets the state associated with the current Hub session, when one can be resolved.
+ ///
+ ///
+ /// The value is when the current URI does not contain a Hub
+ /// session identifier, the identifier is invalid, or no state has been loaded.
+ ///
+ protected HubFlowState? HubState { get; private set; }
+
+ ///
+ /// Gets a value indicating whether the resolved Hub flow exists.
+ ///
+ protected bool HasHub => HubState?.Exists == true;
+
+ ///
+ /// Gets a value indicating whether the resolved Hub flow exists and is active.
+ ///
+ protected bool IsHubActive => HasHub && HubState?.IsActive == true;
+
+ ///
+ /// Gets a value indicating whether the resolved Hub flow has expired.
+ ///
+ protected bool IsExpired => HubState?.IsExpired == true;
+
+ ///
+ /// Gets the error associated with the resolved Hub flow, if any.
+ ///
+ protected HubErrorCode? Error => HubState?.Error;
+
+ private string? _lastHubKey;
+
+ ///
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ var hubKey = ResolveHubKey();
+
+ if (string.IsNullOrWhiteSpace(hubKey))
+ {
+ HubState = null;
+ return;
+ }
+
+ if (_lastHubKey == hubKey && HubState is not null)
+ return;
+
+ _lastHubKey = hubKey;
+
+ if (HubSessionId.TryParse(hubKey, out var hubId))
+ {
+ HubState = await HubFlowReader.GetStateAsync(hubId);
+ }
+ else
+ {
+ HubState = null;
+ }
+ }
+
+ ///
+ /// Resolves the Hub session identifier associated with the current navigation URI.
+ ///
+ ///
+ /// The raw Hub session identifier when present; otherwise, .
+ ///
+ ///
+ /// The default implementation reads from
+ /// the current query string. Derived layouts may override this method to provide
+ /// the Hub session identifier from another source.
+ ///
+ protected virtual string? ResolveHubKey()
+ {
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
+
+ if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue))
+ return hubValue.ToString();
+
+ return null;
+ }
+}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs
deleted file mode 100644
index 48b248c3..00000000
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions;
-using CodeBeam.UltimateAuth.Core.Defaults;
-using CodeBeam.UltimateAuth.Core.Domain;
-using Microsoft.AspNetCore.Components;
-
-namespace CodeBeam.UltimateAuth.Client.Blazor;
-
-public abstract class UAuthHubLayoutComponentBase : LayoutComponentBase
-{
- [Inject] protected NavigationManager Navigation { get; set; } = default!;
- [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!;
-
- protected HubFlowState? HubState { get; private set; }
-
- protected bool HasHub => HubState?.Exists == true;
- protected bool IsHubAuthorized => HasHub && HubState?.IsActive == true;
- protected bool IsExpired => HubState?.IsExpired == true;
- protected HubErrorCode? Error => HubState?.Error;
-
- private string? _lastHubKey;
-
- protected override async Task OnParametersSetAsync()
- {
- await base.OnParametersSetAsync();
-
- var hubKey = ResolveHubKey();
-
- if (string.IsNullOrWhiteSpace(hubKey))
- {
- HubState = null;
- return;
- }
-
- if (_lastHubKey == hubKey && HubState is not null)
- return;
-
- _lastHubKey = hubKey;
-
- if (HubSessionId.TryParse(hubKey, out var hubId))
- {
- HubState = await HubFlowReader.GetStateAsync(hubId);
- }
- else
- {
- HubState = null;
- }
- }
-
- protected virtual string? ResolveHubKey()
- {
- var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
- var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
-
- if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue))
- return hubValue.ToString();
-
- return null;
- }
-}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs
index 2fc629d1..f4063ab0 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs
@@ -5,26 +5,78 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
-public abstract class UAuthHubPageBase : UAuthReactiveComponentBase
+///
+/// Base class for Blazor pages that participate in an UltimateAuth Hub flow.
+///
+///
+///
+/// The page receives the Hub session identifier from the current query string
+/// and exposes the corresponding to derived pages.
+///
+///
+/// Hub state is obtained through and is automatically
+/// loaded when component parameters are processed. Derived pages can explicitly
+/// refresh the state by calling .
+///
+///
+/// This type provides Hub flow state to the UI and does not itself authorize,
+/// complete, or otherwise make security decisions for the authentication flow.
+///
+///
+public abstract class UAuthHubPageBase : UAuthComponentBase
{
+ ///
+ /// Gets the Hub flow reader used to retrieve the state of the current Hub session.
+ ///
[Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!;
- [Inject] protected NavigationManager Nav { get; set; } = default!;
+ ///
+ /// Gets or sets the raw Hub session identifier supplied by the current query string.
+ ///
+ ///
+ /// The value is supplied from .
+ /// It is validated as a before Hub state is read.
+ ///
[Parameter]
[SupplyParameterFromQuery(Name = UAuthConstants.Query.Hub)]
public string? HubKey { get; set; }
+ ///
+ /// Gets the state associated with the current Hub session, when one can be resolved.
+ ///
+ ///
+ /// The value is when no Hub session identifier is supplied
+ /// or when the supplied identifier is invalid.
+ ///
protected HubFlowState? HubState { get; private set; }
- protected bool IsHubAuthorized => HubState is { Exists: true, IsActive: true };
+ ///
+ /// Gets a value indicating whether the resolved Hub flow exists and is active.
+ ///
+ protected bool IsHubActive => HubState is { Exists: true, IsActive: true };
+ ///
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
- await ReloadState();
+ await ReloadStateAsync();
}
- public async Task ReloadState()
+ ///
+ /// Reloads the Hub flow state associated with the current .
+ ///
+ ///
+ ///
+ /// When is missing, empty, or invalid, is cleared.
+ ///
+ ///
+ /// A valid Hub session identifier is resolved through .
+ ///
+ ///
+ ///
+ /// A task that represents the asynchronous reload operation.
+ ///
+ public async Task ReloadStateAsync()
{
if (string.IsNullOrWhiteSpace(HubKey))
{
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs
new file mode 100644
index 00000000..a4af1c65
--- /dev/null
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs
@@ -0,0 +1,189 @@
+ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using Microsoft.AspNetCore.WebUtilities;
+using System.Text;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.Client.Blazor;
+
+///
+/// Base class for UltimateAuth Blazor pages that participate in authentication flows and consume UltimateAuth flow parameters
+/// from the current URL.
+///
+public abstract class UAuthPageBase : UAuthComponentBase
+{
+ ///
+ /// Gets the UltimateAuth flow payload parsed from the current URL, when one is available and valid.
+ ///
+ protected AuthFlowPayload? UAuthPayload { get; private set; }
+
+ ///
+ /// Gets the return URL supplied for the current authentication flow.
+ ///
+ ///
+ /// The value represents input from the current URL and should not be treated as a trusted navigation target without validation.
+ ///
+ protected string? ReturnUrl { get; private set; }
+
+ ///
+ /// Gets whether focus was requested for the current page.
+ ///
+ protected bool ShouldFocus { get; private set; }
+
+ ///
+ /// Gets the identifier supplied for the current authentication flow.
+ ///
+ protected string? Identifier { get; private set; }
+
+ ///
+ /// Gets whether authentication flow query parameters should be removed from the browser URL after they have been parsed.
+ /// Default is true.
+ ///
+ protected virtual bool ClearUAuthQueryAfterParse => true;
+
+ private bool _needsClear;
+ private string? _lastParsedUri;
+ private bool _payloadConsumed;
+
+ ///
+ protected override void OnParametersSet()
+ {
+ base.OnParametersSet();
+
+ var currentUri = Navigation.Uri;
+
+ if (string.Equals(_lastParsedUri, currentUri, StringComparison.Ordinal))
+ return;
+
+ _lastParsedUri = currentUri;
+
+ _payloadConsumed = false;
+
+ var uri = Navigation.ToAbsoluteUri(currentUri);
+ var query = QueryHelpers.ParseQuery(uri.Query);
+
+ ShouldFocus = query.TryGetValue(UAuthConstants.Query.Focus, out var focus) && focus == "1";
+ ReturnUrl = query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) ? ru.ToString() : null;
+ Identifier = query.TryGetValue(UAuthConstants.Query.Identifier, out var id) ? id.ToString() : null;
+
+ UAuthPayload = null;
+
+ if (query.TryGetValue(UAuthConstants.Query.Payload, out var raw) && !string.IsNullOrWhiteSpace(raw))
+ {
+ try
+ {
+ var bytes = WebEncoders.Base64UrlDecode(raw!);
+ var json = Encoding.UTF8.GetString(bytes);
+ UAuthPayload = JsonSerializer.Deserialize(json);
+ }
+ catch
+ {
+ UAuthPayload = null;
+ }
+ }
+
+ _needsClear = ClearUAuthQueryAfterParse && HasUAuthPageQuery(query);
+ }
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ await base.OnAfterRenderAsync(firstRender);
+
+ if (TryConsumePayload(out var payload))
+ await OnUAuthPayloadAsync(payload!);
+
+ if (ConsumeFocus())
+ await OnFocusRequestedAsync();
+
+ if (_needsClear)
+ {
+ _needsClear = false;
+ var cleanUri = BuildUriWithoutConsumedUAuthQuery();
+
+ if (!string.Equals(cleanUri, Navigation.Uri, StringComparison.Ordinal))
+ Navigation.NavigateTo(cleanUri, replace: true);
+ }
+ }
+
+ ///
+ /// Consumes a pending focus request.
+ ///
+ ///
+ /// when a focus request was pending, otherwise .
+ ///
+ protected bool ConsumeFocus()
+ {
+ if (!ShouldFocus)
+ return false;
+
+ ShouldFocus = false;
+ return true;
+ }
+
+ ///
+ /// Attempts to consume the current authentication flow payload. A payload can be consumed only once for a parsed URL.
+ ///
+ protected bool TryConsumePayload(out AuthFlowPayload? payload)
+ {
+ if (_payloadConsumed || UAuthPayload is null)
+ {
+ payload = null;
+ return false;
+ }
+
+ _payloadConsumed = true;
+ payload = UAuthPayload;
+ return true;
+ }
+
+ ///
+ /// Called when a new UltimateAuth flow payload is available for the current page.
+ /// This method is called only once per parsed URL, and only when a valid payload is present.
+ ///
+ ///
+ ///
+ protected virtual Task OnUAuthPayloadAsync(AuthFlowPayload payload) => Task.CompletedTask;
+
+ ///
+ /// Called when a focus request is present for the current page.
+ /// This method is called only once per parsed URL, and only when a focus request is present.
+ ///
+ ///
+ protected virtual Task OnFocusRequestedAsync() => Task.CompletedTask;
+
+ private string BuildUriWithoutConsumedUAuthQuery()
+ {
+ var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
+ var query = QueryHelpers.ParseQuery(uri.Query);
+
+ var remaining = query
+ .Where(x => !IsConsumedUAuthQueryParameter(x.Key))
+ .SelectMany(
+ x => x.Value,
+ (x, value) => new KeyValuePair(
+ x.Key,
+ value));
+
+ return QueryHelpers.AddQueryString(
+ uri.GetLeftPart(UriPartial.Path),
+ remaining);
+ }
+
+ private static bool IsConsumedUAuthQueryParameter(string key)
+ {
+ return key is
+ UAuthConstants.Query.Payload or
+ UAuthConstants.Query.Focus or
+ UAuthConstants.Query.ReturnUrl or
+ UAuthConstants.Query.Identifier;
+ }
+
+ private static bool HasUAuthPageQuery(IDictionary query)
+ {
+ return query.ContainsKey(UAuthConstants.Query.Payload)
+ || query.ContainsKey(UAuthConstants.Query.Focus)
+ || query.ContainsKey(UAuthConstants.Query.ReturnUrl)
+ || query.ContainsKey(UAuthConstants.Query.Identifier);
+ }
+}
\ No newline at end of file
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs
index ef31c4b2..46ec4b79 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs
@@ -3,46 +3,121 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
+///
+/// Provides the root Blazor integration component for UltimateAuth.
+///
+///
+///
+/// initializes the UltimateAuth client runtime,
+/// exposes the current as a cascading value,
+/// coordinates the authenticated session lifecycle, and optionally provides
+/// the application's Blazor router.
+///
+///
+/// Applications using UltimateAuth Blazor components should normally place
+/// their application content within this component.
+///
+///
+/// Client-side authentication state is intended for UI behavior only and
+/// does not constitute a security boundary. Authorization of protected
+/// resources must always be enforced by the server.
+///
+///
public partial class UAuthApp
{
private bool _initialized;
private bool _coordinatorStarted;
+ ///
+ /// Gets or sets the application content rendered within the UltimateAuth context.
+ ///
[Parameter]
public RenderFragment? ChildContent { get; set; }
+ ///
+ /// Gets or sets the content rendered by the built-in router when the current user is not authorized to access a route.
+ ///
+ ///
+ /// This parameter is used only when is enabled.
+ ///
[Parameter]
public RenderFragment? NotAuthorized { get; set; }
+ ///
+ /// Gets or sets whether should provide the application's Blazor router.
+ ///
+ ///
+ /// Set this to when the application provides its own router.
+ ///
[Parameter]
public bool UseBuiltInRouter { get; set; }
+ ///
+ /// Gets or sets whether UltimateAuth client routes are included in the assemblies searched by the built-in router.
+ ///
+ ///
+ /// The default value is .
+ ///
[Parameter]
public bool UseUAuthClientRoutes { get; set; } = true;
+ ///
+ /// Gets or sets the assembly containing the application's routable components.
+ ///
+ ///
+ /// This value is used by the built-in router.
+ ///
[Parameter]
public Assembly? AppAssembly { get; set; }
+ ///
+ /// Gets or sets additional assemblies that should be searched for routable components.
+ ///
+ ///
+ /// When is enabled, the UltimateAuth
+ /// Blazor client assemblies are added to this set automatically.
+ ///
[Parameter]
public IEnumerable? AdditionalAssemblies { get; set; }
+ ///
+ /// Gets or sets the default layout used by the built-in
+ /// .
+ ///
[Parameter]
public Type? DefaultLayout { get; set; }
+ ///
+ /// Gets or sets the CSS selector used by Blazor's focus-on-navigation behavior.
+ ///
+ ///
+ /// The default value is h1.
+ ///
[Parameter]
public string? FocusSelector { get; set; } = "h1";
+ ///
+ /// Gets or sets how UltimateAuth state changes affect component rendering.
+ ///
+ ///
+ /// The default value is .
+ ///
[Parameter]
public UAuthRenderMode RenderMode { get; set; } = UAuthRenderMode.Manual;
+ ///
+ /// Gets or sets the callback invoked when the session coordinator determines that reauthentication is required.
+ ///
[Parameter]
public EventCallback OnReauthRequired { get; set; }
+ ///
protected override async Task OnInitializedAsync()
{
Coordinator.ReauthRequired += HandleReauthRequired;
}
+ ///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@@ -81,6 +156,7 @@ private void OnStateChanged(UAuthStateChangeReason reason)
_ = InvokeAsync(async () =>
{
await Coordinator.StartAsync();
+ _coordinatorStarted = true;
});
}
@@ -116,6 +192,10 @@ private IEnumerable GetAdditionalAssemblies()
return Enumerable.Empty();
}
+ ///
+ /// Disposes the component and stops the session coordinator if it was started.
+ ///
+ ///
public async ValueTask DisposeAsync()
{
StateManager.State.Changed -= OnStateChanged;
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
index 93eb2736..6a57f226 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor
@@ -27,7 +27,7 @@
@if (SubmitMode == UAuthSubmitMode.DirectCommit)
{
-
+
}
@ChildContent
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs
index 8696afd6..7ff71a56 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs
@@ -10,6 +10,9 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
+///
+/// Represents a Blazor component that provides a login form for UltimateAuth authentication.
+///
public partial class UAuthLoginForm
{
[Inject]
@@ -98,6 +101,7 @@ public partial class UAuthLoginForm
private HubFlowState? _flow;
private DeviceId? _deviceId;
+ ///
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
@@ -113,6 +117,7 @@ protected override async Task OnParametersSetAsync()
}
}
+ ///
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
@@ -122,6 +127,10 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
StateHasChanged();
}
+ ///
+ /// Asynchronously reloads the credentials associated with the current hub session, if applicable.
+ ///
+ ///
protected async Task ReloadCredentialsAsync()
{
if (LoginType != UAuthLoginType.Pkce)
@@ -133,6 +142,10 @@ protected async Task ReloadCredentialsAsync()
_credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value);
}
+ ///
+ /// Asynchronously reloads the state associated with the current hub session, if applicable.
+ ///
+ ///
protected async Task ReloadStateAsync()
{
if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null)
@@ -286,7 +299,9 @@ private string ResolvedEndpoint
if (_credentials != null && EffectiveHubSessionId is not null)
{
- query.Add($"hub={EffectiveHubSessionId}");
+ query.Add(
+ $"{UAuthConstants.Query.Hub}=" +
+ $"{Uri.EscapeDataString(EffectiveHubSessionId.Value.Value)}");
}
if (!string.IsNullOrWhiteSpace(returnUrl))
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor
index 49e15f69..1bcc37df 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor
@@ -1,4 +1,4 @@
ο»Ώ@namespace CodeBeam.UltimateAuth.Client.Blazor
-@inherits UAuthReactiveComponentBase
+@inherits UAuthComponentBase
@ChildContent
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs
index c3c4e2dd..9240307a 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs
@@ -2,8 +2,15 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
-public partial class UAuthScope : UAuthReactiveComponentBase
+///
+/// A Blazor component that defines a scope for UltimateAuth authentication and authorization.
+/// It can be used to group child components that require specific authentication or authorization context.
+///
+public partial class UAuthScope : UAuthComponentBase
{
+ ///
+ /// Gets or sets the child content to be rendered within this scope.
+ ///
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor
index fca197bb..1f9227b4 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor
@@ -1,9 +1,7 @@
ο»Ώ@namespace CodeBeam.UltimateAuth.Client.Blazor
-@inherits UAuthReactiveComponentBase
-@using CodeBeam.UltimateAuth.Core.Domain
+@inherits UAuthComponentBase
@using Microsoft.AspNetCore.Authorization
-@using Microsoft.AspNetCore.Components.Authorization
@inject IAuthorizationService AuthorizationService
@if (_inactive)
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs
index e488b329..bdce111b 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs
@@ -1,10 +1,14 @@
-ο»Ώusing CodeBeam.UltimateAuth.Core.Domain;
+ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
namespace CodeBeam.UltimateAuth.Client.Blazor;
-public partial class UAuthStateView : UAuthReactiveComponentBase
+///
+/// A Blazor component that conditionally renders content based on the current UltimateAuth authentication state and authorization requirements.
+///
+public partial class UAuthStateView : UAuthComponentBase
{
private IReadOnlyList _rolesParsed = Array.Empty();
private IReadOnlyList _permissionsParsed = Array.Empty();
@@ -15,40 +19,87 @@ public partial class UAuthStateView : UAuthReactiveComponentBase
private string? _rolesRaw;
private string? _permissionsRaw;
+ ///
+ /// Gets or sets the content to render when the user is authorized. The content receives the current as a parameter.
+ ///
[Parameter]
public RenderFragment? Authorized { get; set; }
+ ///
+ /// Gets or sets the content to render when the user is not authorized. This content is displayed when the user does not meet the specified authorization requirements.
+ ///
[Parameter]
public RenderFragment? NotAuthorized { get; set; }
+ ///
+ /// Gets or sets the content to render when the user is inactive. This content is displayed when the user's session state is not active, and the parameter is set to true.
+ ///
[Parameter]
public RenderFragment? Inactive { get; set; }
+ ///
+ /// Gets or sets the content to render while the authorization evaluation is in progress. This content is displayed when the component is determining whether the user meets the specified authorization requirements.
+ ///
[Parameter]
public RenderFragment? Authorizing { get; set; }
+ ///
+ /// Gets or sets the content to render regardless of the user's authorization state. This content is always displayed, and it receives the current as a parameter.
+ ///
[Parameter]
public RenderFragment? ChildContent { get; set; }
+ ///
+ /// Gets or sets a comma-separated list of roles that the user must have to be considered authorized. The roles are evaluated based on the specified .
+ ///
[Parameter]
public string? Roles { get; set; }
+ ///
+ /// Gets or sets a comma-separated list of permissions that the user must have to be considered authorized. The permissions are evaluated based on the specified .
+ ///
[Parameter]
public string? Permissions { get; set; }
+ ///
+ /// Gets or sets the name of a policy that the user must satisfy to be considered authorized. The policy is evaluated based on the specified .
+ ///
[Parameter]
public string? Policy { get; set; }
///
- /// Gets or sets a value indicating whether all set conditions must be matched for the operation to succeed.
- /// Null parameters don't count as condition.
+ /// Determines how authorization conditions are evaluated.
+ ///
+ ///
+ /// :
+ /// Any configured condition may succeed.
+ ///
+ ///
+ ///
+ /// :
+ /// All configured conditions and values must succeed.
+ ///
+ ///
+ ///
+ /// :
+ /// At least one value from each configured category must succeed.
+ /// For example:
+ /// one matching role AND one matching permission.
+ ///
+ ///
+ /// Null or empty parameters are ignored.
///
[Parameter]
- public bool MatchAll { get; set; } = true;
+ public AuthorizationMatchMode MatchMode { get; set; } = AuthorizationMatchMode.Category;
+ ///
+ /// Gets or sets a value indicating whether the user's session state must be active for the user to be considered authorized.
+ /// If set to true, the component will evaluate the user's session state and render the content if the session is not active.
+ ///
[Parameter]
public bool RequireActive { get; set; } = true;
+ ///
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
@@ -78,6 +129,11 @@ protected override async Task OnParametersSetAsync()
_authorizing = false;
}
+ ///
+ /// Handles changes in the authentication state.
+ /// This method is called when the authentication state changes, and it evaluates the current session state and authorization requirements.
+ ///
+ ///
protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason)
{
EvaluateSessionState();
@@ -92,34 +148,76 @@ private async Task EvaluateAuthorizationAsync()
if (!AuthState.IsAuthenticated)
return false;
- var roles = _rolesParsed;
- var permissions = _permissionsParsed;
+ var hasRoles = _rolesParsed.Count > 0;
+ var hasPermissions = _permissionsParsed.Count > 0;
+ var hasPolicy = !string.IsNullOrWhiteSpace(Policy);
+
+ // No explicit authorization requirements:
+ // authentication itself is sufficient.
+ if (!hasRoles && !hasPermissions && !hasPolicy)
+ return true;
+
+ var roleResults = _rolesParsed
+ .Select(AuthState.IsInRole)
+ .ToList();
+
+ var permissionResults = _permissionsParsed
+ .Select(AuthState.HasPermission)
+ .ToList();
- var results = new List();
+ bool? policyResult = null;
- if (roles.Count > 0)
+ if (!string.IsNullOrWhiteSpace(Policy))
{
- results.Add(MatchAll
- ? roles.All(AuthState.IsInRole)
- : roles.Any(AuthState.IsInRole));
+ policyResult = await EvaluatePolicyAsync();
}
- if (permissions.Count > 0)
+ return MatchMode switch
{
- results.Add(MatchAll
- ? permissions.All(AuthState.HasPermission)
- : permissions.Any(AuthState.HasPermission));
- }
+ AuthorizationMatchMode.Any
+ => EvaluateAny(roleResults, permissionResults, policyResult),
- if (!string.IsNullOrWhiteSpace(Policy))
- results.Add(await EvaluatePolicyAsync());
+ AuthorizationMatchMode.All
+ => EvaluateAll(roleResults, permissionResults, policyResult),
- if (results.Count == 0)
- return true;
+ AuthorizationMatchMode.Category
+ => EvaluateCategory(roleResults, permissionResults, policyResult),
+
+ _ => false
+ };
+ }
+
+ private static bool EvaluateAny(IReadOnlyList roles, IReadOnlyList permissions, bool? policy)
+ {
+ return roles.Any(x => x) || permissions.Any(x => x) || policy == true;
+ }
+
+ private static bool EvaluateAll(IReadOnlyList roles, IReadOnlyList permissions, bool? policy)
+ {
+ if (roles.Count > 0 && roles.Any(x => !x))
+ return false;
+
+ if (permissions.Count > 0 && permissions.Any(x => !x))
+ return false;
+
+ if (policy.HasValue && !policy.Value)
+ return false;
+
+ return true;
+ }
+
+ private static bool EvaluateCategory(IReadOnlyList roles, IReadOnlyList permissions, bool? policy)
+ {
+ if (roles.Count > 0 && !roles.Any(x => x))
+ return false;
+
+ if (permissions.Count > 0 && !permissions.Any(x => x))
+ return false;
+
+ if (policy.HasValue && !policy.Value)
+ return false;
- return MatchAll
- ? results.All(x => x)
- : results.Any(x => x);
+ return true;
}
private void EvaluateSessionState()
@@ -171,6 +269,6 @@ private async Task EvaluatePolicyAsync()
private string BuildAuthKey()
{
- return $"{Roles}|{Permissions}|{Policy}|{MatchAll}";
+ return $"{Roles}|{Permissions}|{Policy}|{MatchMode}{RequireActive}";
}
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs
index b64b221b..1ef3de09 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs
@@ -4,16 +4,25 @@
namespace CodeBeam.UltimateAuth.Client.Blazor.Device;
+///
+/// Represents a device ID storage implementation that uses browser client storage to persist the device ID.
+///
public sealed class BrowserDeviceIdStorage : IDeviceIdStorage
{
private const string Key = "udid";
private readonly IClientStorage _storage;
+ ///
public BrowserDeviceIdStorage(IClientStorage storage)
{
_storage = storage;
}
+ ///
+ /// Loads the device ID from the browser client storage.
+ ///
+ ///
+ ///
public async ValueTask LoadAsync(CancellationToken ct = default)
{
try
@@ -29,6 +38,12 @@ public BrowserDeviceIdStorage(IClientStorage storage)
}
}
+ ///
+ /// Saves the device ID to the browser client storage.
+ ///
+ ///
+ ///
+ ///
public ValueTask SaveAsync(string deviceId, CancellationToken ct = default)
{
return _storage.SetAsync(StorageScope.Local, Key, deviceId);
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs
index 3c0cb7aa..2411bfc2 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs
@@ -2,8 +2,17 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;
+///
+/// Provides extension methods for working with assemblies in the context of UltimateAuth Blazor client applications.
+///
public static class UAuthAssemblies
{
+ ///
+ /// Appends the assembly containing the UAuthBlazorClientMarker class to the provided collection of assemblies,
+ /// ensuring that it is included for UltimateAuth Blazor client applications.
+ ///
+ ///
+ ///
public static Assembly[] WithUltimateAuth(this IEnumerable? assemblies)
{
var authAssembly = typeof(UAuthBlazorClientMarker).Assembly;
@@ -14,6 +23,11 @@ public static Assembly[] WithUltimateAuth(this IEnumerable? assemblies
return assemblies.Append(authAssembly).DistinctBy(a => a.FullName).ToArray();
}
+ ///
+ /// Returns an array containing the assembly of the UAuthBlazorClientMarker class,
+ /// which is used to identify the UltimateAuth Blazor client application assembly.
+ ///
+ ///
public static Assembly[] BlazorClient()
{
return new[] { typeof(UAuthBlazorClientMarker).Assembly };
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs
index 017c43a5..46436d41 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs
@@ -4,27 +4,64 @@
namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure;
+///
+/// Represents a client storage implementation that uses the browser's localStorage and sessionStorage via JavaScript interop.
+///
public sealed class BrowserClientStorage : IClientStorage
{
private readonly IJSRuntime _js;
+ ///
+ /// Initializes a new instance of the class with the specified JavaScript runtime.
+ ///
+ ///
public BrowserClientStorage(IJSRuntime js)
{
_js = js;
}
+ ///
+ /// Sets a value in the specified storage scope (localStorage or sessionStorage) with the given key.
+ ///
+ ///
+ ///
+ ///
+ ///
public ValueTask SetAsync(StorageScope scope, string key, string value)
=> _js.InvokeVoidAsync("uauth.storage.set", Scope(scope), key, value);
+ ///
+ /// Gets a value from the specified storage scope (localStorage or sessionStorage) with the given key.
+ ///
+ ///
+ ///
+ ///
public ValueTask GetAsync(StorageScope scope, string key)
=> _js.InvokeAsync("uauth.storage.get", Scope(scope), key);
+ ///
+ /// Removes a value from the specified storage scope (localStorage or sessionStorage) with the given key.
+ ///
+ ///
+ ///
+ ///
public ValueTask RemoveAsync(StorageScope scope, string key)
=> _js.InvokeVoidAsync("uauth.storage.remove", Scope(scope), key);
-
+
+ ///
+ /// Checks if a value exists in the specified storage scope (localStorage or sessionStorage) with the given key.
+ ///
+ ///
+ ///
+ ///
public async ValueTask ExistsAsync(StorageScope scope, string key)
=> await _js.InvokeAsync("uauth.storage.exists", Scope(scope), key);
+ ///
+ /// Gets the string representation of the storage scope for use in JavaScript interop.
+ ///
+ ///
+ ///
private static string Scope(StorageScope scope)
=> scope == StorageScope.Local ? "local" : "session";
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
index 7e1d5999..a4b93b3e 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs
@@ -2,10 +2,20 @@
namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Discovers the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "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/login". If multiple components are found, an exception is thrown.
+/// The resolved route is cached for subsequent calls.
+///
public static class UAuthLoginPageDiscovery
{
private static string? _cached;
+ ///
+ /// Resolves the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "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/login".
+ /// If multiple components are found, an exception is thrown.
+ ///
+ ///
+ ///
public static string Resolve()
{
if (_cached != null)
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
index 63662572..38c7fe96 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
@@ -91,6 +91,8 @@ public async Task SendJsonAsync(string endpoint, object? p
public async Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
await _bootstrapper.EnsureStartedAsync();
var response = await _js.InvokeAsync(
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs
index d7174ce1..affb92b8 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs
@@ -1,5 +1,9 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Blazor;
+///
+/// Marker class for the UltimateAuth Blazor client library.
+/// This class is used to identify the assembly and provide a reference point for dependency injection and other framework features.
+///
public class UAuthBlazorClientMarker
{
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js
index 28aff8b9..61a3dfaf 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js
@@ -1 +1 @@
-ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()}};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}}
\ No newline at end of file
+ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}}
\ No newline at end of file
diff --git a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj
index f6abad23..cdd30716 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj
+++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs
index d307fbdc..a39a6075 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs
@@ -2,7 +2,14 @@
namespace CodeBeam.UltimateAuth.Client.Abstractions;
+///
+/// Provides a mechanism to retrieve the device context for the client application.
+///
public interface IClientDeviceProvider
{
+ ///
+ /// Retrieves the device context for the client application asynchronously.
+ ///
+ ///
Task GetAsync();
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs
index 9f605d26..2cbb2793 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs
@@ -2,10 +2,28 @@
namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Represents a storage mechanism for client-side data, allowing for setting, retrieving, removing, and checking the existence of key-value pairs within specified storage scopes.
+///
public interface IClientStorage
{
+ ///
+ /// Sets a value in the specified storage scope with the given key.
+ ///
ValueTask SetAsync(StorageScope scope, string key, string value);
+
+ ///
+ /// Retrieves a value from the specified storage scope using the given key. Returns null if the key does not exist.
+ ///
ValueTask GetAsync(StorageScope scope, string key);
+
+ ///
+ /// Removes a value from the specified storage scope using the given key.
+ ///
ValueTask RemoveAsync(StorageScope scope, string key);
+
+ ///
+ /// Checks if a value exists in the specified storage scope for the given key.
+ ///
ValueTask ExistsAsync(StorageScope scope, string key);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs
index f5eddd8e..0567ef25 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs
@@ -1,6 +1,12 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Abstractions;
+///
+/// Represents a provider that can retrieve the current return URL, typically used in authentication flows to redirect users back to their original destination after login or other actions.
+///
public interface IReturnUrlProvider
{
+ ///
+ /// Gets the current return URL, which is the URL to which the user should be redirected after completing an authentication flow or other relevant action.
+ ///
string GetCurrentUrl();
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs
index ce1781aa..cec583af 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs
@@ -1,5 +1,9 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Abstractions;
+///
+/// Represents a coordinator for managing user sessions, providing methods to start and stop session coordination,
+/// and an event to notify when reauthentication is required.
+///
public interface ISessionCoordinator : IAsyncDisposable
{
///
@@ -13,5 +17,8 @@ public interface ISessionCoordinator : IAsyncDisposable
///
Task StopAsync();
+ ///
+ /// Event triggered when reauthentication is required.
+ ///
event Action? ReauthRequired;
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs
index cbc32fbe..ae41b93d 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs
@@ -10,17 +10,28 @@ namespace CodeBeam.UltimateAuth.Client;
///
/// Represents the client-side authentication snapshot for UltimateAuth.
-///
+///
/// This is a lightweight, memory-only view of the current authentication state.
/// It is not a security boundary and must always be validated server-side.
+///
///
public sealed class UAuthState
{
private UAuthState() { }
+ ///
+ /// Gets the current authenticated identity snapshot, or null if the user is not authenticated.
+ ///
public AuthIdentitySnapshot? Identity { get; private set; }
+
+ ///
+ /// Gets the current claims snapshot for the authenticated user, or an empty snapshot if the user is not authenticated.
+ ///
public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty;
+ ///
+ /// Gets the timestamp of the last successful validation of the authentication state, or null if it has never been validated.
+ ///
public DateTimeOffset? LastValidatedAt { get; private set; }
///
@@ -28,13 +39,25 @@ private UAuthState() { }
///
public bool IsStale { get; private set; }
-
+ ///
+ /// Occurs when the authentication state has changed, such as after login, logout, or profile updates.
+ ///
public event Action? Changed;
internal Action? RequestRender;
+ ///
+ /// Gets a value indicating whether the user is currently authenticated (i.e., has a valid identity).
+ ///
public bool IsAuthenticated => Identity is not null;
+
+ ///
+ /// Gets a value indicating whether the authentication state needs to be validated (i.e., the user is authenticated but the snapshot is stale).
+ ///
public bool NeedsValidation => IsAuthenticated && IsStale;
+ ///
+ /// Creates a new anonymous (unauthenticated) instance of .
+ ///
public static UAuthState Anonymous() => new();
internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt)
@@ -96,6 +119,9 @@ internal void MarkStale()
Changed?.Invoke(UAuthStateChangeReason.MarkedStale);
}
+ ///
+ /// Marks the authentication state as stale and requests a re-render of the UI.
+ ///
public void Touch(bool updateState = true)
{
if (updateState)
@@ -116,9 +142,20 @@ internal void Clear()
Changed?.Invoke(UAuthStateChangeReason.Cleared);
}
+ ///
+ /// Determines whether the current authenticated user is in the specified role.
+ ///
+ ///
+ ///
public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role);
private CompiledPermissionSet? _compiledPermissions;
+
+ ///
+ /// Determines whether the current authenticated user has the specified permission.
+ ///
+ ///
+ ///
public bool HasPermission(string permission)
{
if (!IsAuthenticated)
@@ -130,6 +167,11 @@ public bool HasPermission(string permission)
return _compiledPermissions?.IsAllowed(permission) == true;
}
+ ///
+ /// Determines whether the current authenticated user has any of the specified permissions.
+ ///
+ ///
+ ///
public bool HasAnyPermission(params string[] permissions)
{
foreach (var perm in permissions)
@@ -141,8 +183,16 @@ public bool HasAnyPermission(params string[] permissions)
return false;
}
+ ///
+ /// Determines whether the current authenticated user has the specified claim type and value.
+ ///
public bool HasClaim(string type, string value) => IsAuthenticated && Claims.HasValue(type, value);
+ ///
+ /// Gets the value of the specified claim type for the current authenticated user, or null if the claim does not exist or the user is not authenticated.
+ ///
+ ///
+ ///
public string? GetClaim(string type) => IsAuthenticated ? Claims.Get(type) : null;
///
diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs
index 881df3f2..64af51cb 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs
@@ -1,11 +1,37 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Describes why the UltimateAuth client authentication state changed.
+///
public enum UAuthStateChangeReason
{
+ ///
+ /// The state was updated with an authenticated identity snapshot.
+ ///
Authenticated,
+
+ ///
+ /// The current authentication state was successfully validated.
+ ///
Validated,
+
+ ///
+ /// The current authentication state was marked as requiring validation.
+ ///
MarkedStale,
+
+ ///
+ /// The authentication state was cleared.
+ ///
Cleared,
+
+ ///
+ /// The state was explicitly touched to request an update or render.
+ ///
Touched,
+
+ ///
+ /// The current authenticated state was updated without replacing the full snapshot.
+ ///
Patched
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs
index 6367d6a1..80a995e3 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs
@@ -1,15 +1,57 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Identifies events that may affect the UltimateAuth client authentication state.
+///
public enum UAuthStateEvent
{
+ ///
+ /// Indicates that authentication state validation was requested.
+ ///
ValidationCalled,
+
+ ///
+ /// Indicates that one or more identifiers associated with the user changed.
+ ///
IdentifiersChanged,
+
+ ///
+ /// Indicates that the user's status changed.
+ ///
UserStatusChanged,
+
+ ///
+ /// Indicates that the user's profile information changed.
+ ///
ProfileChanged,
+
+ ///
+ /// Indicates that credentials associated with a user changed.
+ ///
CredentialsChanged,
+
+ ///
+ /// Indicates that the current user's own credentials changed.
+ ///
CredentialsChangedSelf,
+
+ ///
+ /// Indicates that authorization information associated with the user changed.
+ ///
AuthorizationChanged,
+
+ ///
+ /// Indicates that a session associated with the user was revoked.
+ ///
SessionRevoked,
+
+ ///
+ /// Indicates that the user was deleted.
+ ///
UserDeleted,
+
+ ///
+ /// Indicates that a logout operation affecting the authentication state occurred.
+ ///
LogoutVariant
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs
index 173f6526..00ed1d9f 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs
@@ -1,8 +1,23 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Specifies how the UltimateAuth client authentication state handles a state event.
+///
public enum UAuthStateEventHandlingMode
{
+ ///
+ /// Applies the event as a local update to the current authentication state
+ /// without performing full state validation.
+ ///
Patch,
+
+ ///
+ /// Revalidates the authentication state in response to the event.
+ ///
Validate,
+
+ ///
+ /// Performs no authentication state update in response to the event.
+ ///
None
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
index fa4eaed0..f7f329c1 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
+++ b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj
@@ -2,7 +2,6 @@
net8.0;net9.0;net10.0
- $(NoWarn);1591
CodeBeam.UltimateAuth.Client
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs
index 3b079766..d3b69728 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs
@@ -1,7 +1,17 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts;
+///
+/// Specifies why an UltimateAuth session coordinator stopped its active coordination cycle.
+///
public enum CoordinatorTerminationReason
{
+ ///
+ /// Indicates that no specific termination reason was reported.
+ ///
None = 0,
- ReauthRequired = 1
+
+ ///
+ /// Indicates that the current authentication context requires the user to reauthenticate.
+ ///
+ ReauthRequired = 10
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs
index f04f078b..82206fe2 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs
@@ -2,9 +2,23 @@
namespace CodeBeam.UltimateAuth.Client.Contracts;
+///
+/// Represents the result of an UltimateAuth session or token refresh operation.
+///
public sealed record RefreshResult
{
+ ///
+ /// Gets a value indicating whether the refresh operation completed successfully.
+ ///
public bool IsSuccess { get; init; }
+
+ ///
+ /// Gets the status code associated with the refresh operation.
+ ///
public int Status { get; init; }
+
+ ///
+ /// Gets the semantic outcome of the refresh operation.
+ ///
public RefreshOutcome Outcome { get; init; }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs
index 322f397c..3a7120cb 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs
@@ -1,7 +1,17 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts;
+///
+/// Specifies the browser storage scope used for UltimateAuth client data.
+///
public enum StorageScope
{
- Session,
- Local
+ ///
+ /// Stores data in storage scoped to the current browser session.
+ ///
+ Session = 0,
+
+ ///
+ /// Stores data in persistent browser-local storage.
+ ///
+ Local = 10
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs
index 6a13be2f..d470db9d 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs
@@ -1,8 +1,22 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts;
+///
+/// Specifies how tenant context is transported with UltimateAuth client requests.
+///
public enum TenantTransport
{
- None,
- Header,
- Route
+ ///
+ /// Does not explicitly include tenant context in the request transport.
+ ///
+ None = 0,
+
+ ///
+ /// Includes tenant context in an HTTP request header.
+ ///
+ Header = 10,
+
+ ///
+ /// Includes tenant context as part of the request route.
+ ///
+ Route = 20
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs
index f7f1cbba..ae3dd867 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs
@@ -1,7 +1,18 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Specifies how UltimateAuth authentication state changes affect UI rendering.
+///
public enum UAuthRenderMode
{
+ ///
+ /// Does not automatically request a UI re-render in response to authentication
+ /// state change notifications.
+ ///
Manual = 0,
- Reactive = 1
+
+ ///
+ /// Automatically requests a UI re-render in response to authentication state change notifications.
+ ///
+ Reactive = 10
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs
index 4875462b..bf0f175a 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs
@@ -1,8 +1,22 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Specifies how an UltimateAuth authentication submission is executed.
+///
public enum UAuthSubmitMode
{
+ ///
+ /// Commits the authentication operation directly without first returning a structured try result to the caller.
+ ///
DirectCommit = 0,
+
+ ///
+ /// Attempts the authentication operation and returns its result without committing a successful authentication.
+ ///
TryOnly = 10,
+
+ ///
+ /// Attempts the authentication operation and, when successful, commits the authentication flow.
+ ///
TryAndCommit = 20,
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs
index 1fd9fc47..0e87ba67 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs
@@ -3,17 +3,36 @@
namespace CodeBeam.UltimateAuth.Client.Contracts;
+///
+/// Represents the transport-level result of an UltimateAuth client request.
+///
+///
+/// This type describes the response received by the client transport layer.
+/// Application-level authentication results may be represented separately by more specific UltimateAuth result types.
+///
public sealed class UAuthTransportResult
{
+ ///
+ /// Gets a value indicating whether the transport response represents a successful operation.
+ ///
[JsonPropertyName("ok")]
public bool Ok { get; init; }
+ ///
+ /// Gets the HTTP status code returned by the request.
+ ///
[JsonPropertyName("status")]
public int Status { get; init; }
+ ///
+ /// Gets the refresh outcome reported by the transport response, when available.
+ ///
[JsonPropertyName("refreshOutcome")]
public string? RefreshOutcome { get; init; }
+ ///
+ /// Gets the response body as JSON, when a body is available.
+ ///
[JsonPropertyName("body")]
public JsonElement? Body { get; init; }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs
index 033d82f0..bf73fae4 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs
@@ -2,7 +2,13 @@
namespace CodeBeam.UltimateAuth.Client.Device;
+///
+/// Represents a generator for device identifiers.
+///
public interface IDeviceIdGenerator
{
+ ///
+ /// Generates a new device identifier.
+ ///
DeviceId Generate();
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs
index a9be9fdd..1e60ab73 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs
@@ -2,7 +2,13 @@
namespace CodeBeam.UltimateAuth.Client;
+///
+/// Provides a mechanism to retrieve or create a unique device identifier for the client application.
+///
public interface IDeviceIdProvider
{
+ ///
+ /// Retrieves the existing device identifier or creates a new one if it doesn't exist.
+ ///
ValueTask GetOrCreateAsync(CancellationToken ct = default);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs
index c91457d3..e6bbd169 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs
@@ -1,7 +1,22 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Device;
+///
+/// Represents a storage mechanism for device identifiers.
+///
public interface IDeviceIdStorage
{
+ ///
+ /// Loads the device identifier asynchronously.
+ ///
+ ///
+ ///
ValueTask LoadAsync(CancellationToken ct = default);
+
+ ///
+ /// Saves the device identifier asynchronously.
+ ///
+ ///
+ ///
+ ///
ValueTask SaveAsync(string deviceId, CancellationToken ct = default);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs
index eb21d03a..03008f9c 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs
@@ -2,36 +2,117 @@
namespace CodeBeam.UltimateAuth.Client.Diagnostics;
+///
+/// Represents diagnostic information for the UAuth client, tracking its lifecycle events and refresh attempts.
+///
public sealed class UAuthClientDiagnostics
{
private int _terminatedCount;
+ ///
+ /// Occurs when any diagnostic information changes, allowing subscribers to react to updates in the client's state.
+ ///
public event Action? Changed;
+ ///
+ /// Gets the timestamp when the client was started, or null if it has not been started yet.
+ ///
public DateTimeOffset? StartedAt { get; private set; }
+
+ ///
+ /// Gets the timestamp when the client was stopped, or null if it has not been stopped yet.
+ ///
public DateTimeOffset? StoppedAt { get; private set; }
+
+ ///
+ /// Gets the timestamp when the client was terminated, or null if it has not been terminated yet.
+ ///
public DateTimeOffset? TerminatedAt { get; private set; }
+
+ ///
+ /// Gets a value indicating whether the client is currently running, which is true if it has been started and has not been stopped or terminated.
+ ///
public bool IsRunning => StartedAt is not null && !IsStopped && !IsTerminated;
+
+ ///
+ /// Gets a value indicating whether the client has been stopped, which is true if it has a non-null StoppedAt timestamp.
+ ///
public bool IsStopped => StoppedAt is not null;
+
+ ///
+ /// Gets a value indicating whether the client has been terminated, which is true if it has a non-null TerminatedAt timestamp.
+ ///
public bool IsTerminated { get; private set; }
+
+ ///
+ /// Gets the reason for the client's termination, or null if it has not been terminated.
+ /// This provides context for why the client was terminated, such as due to an error or a manual stop request.
+ ///
public CoordinatorTerminationReason? TerminationReason { get; private set; }
+
+ ///
+ /// Gets the total number of times the client has been terminated, which is incremented each time the MarkTerminated method is called.
+ ///
public int TerminatedCount => _terminatedCount;
+
+ ///
+ /// Gets the total number of times the client has been started and stopped, which are incremented each time the MarkStarted and MarkStopped methods are called, respectively.
+ ///
public int StartCount { get; private set; }
+
+ ///
+ /// Gets the total number of times the client has been stopped, which is incremented each time the MarkStopped method is called.
+ ///
public int StopCount { get; private set; }
+
+ ///
+ /// Gets the total number of refresh attempts made by the client, which is incremented each time either the MarkManualRefresh or MarkAutomaticRefresh methods are called.
+ ///
public int RefreshAttemptCount { get; private set; }
+
+ ///
+ /// Gets the total number of manual refresh attempts made by the client, which is incremented each time the MarkManualRefresh method is called.
+ ///
public int ManualRefreshCount { get; private set; }
+
+ ///
+ /// Gets the total number of automatic refresh attempts made by the client, which is incremented each time the MarkAutomaticRefresh method is called.
+ ///
public int AutomaticRefreshCount { get; private set; }
-
+
+
+ ///
+ /// Gets the total number of refresh attempts that resulted in a "touched" state, which is incremented each time the MarkRefreshTouched method is called.
+ ///
public int RefreshTouchedCount { get; private set; }
+
+ ///
+ /// Gets the total number of refresh attempts that resulted in a "rotated" state, which is incremented each time the MarkRefreshRotated method is called.
+ ///
public int RefreshRotatedCount { get; private set; }
+
+ ///
+ /// Gets the total number of refresh attempts that resulted in a "no operation" state, which is incremented each time the MarkRefreshNoOp method is called.
+ ///
public int RefreshNoOpCount { get; private set; }
+
+ ///
+ /// Gets the total number of refresh attempts that required reauthentication, which is incremented each time the MarkRefreshReauthRequired method is called.
+ ///
public int RefreshReauthRequiredCount { get; private set; }
+
+ ///
+ /// Gets the total number of successful refresh attempts, which is incremented each time the MarkRefreshSuccess method is called.
+ ///
public int RefreshSuccessCount { get; private set; }
+ ///
+ /// Gets the total duration for which the client has been running, calculated as the difference between the StartedAt timestamp and either the StoppedAt or TerminatedAt timestamp, or the current time if the client is still running. Returns null if the client has not been started yet.
+ ///
public TimeSpan? RunningDuration =>
StartedAt is null
? null
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs
index 2098052a..54798cf1 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs
@@ -1,6 +1,12 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Represents a bridge for browser-specific operations in the UltimateAuth client.
+///
public interface IBrowserUAuthBridge
{
+ ///
+ /// Sets the device ID in the browser's local storage or cookies.
+ ///
ValueTask SetDeviceIdAsync(string deviceId);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs
index 13b5b154..79c72386 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs
@@ -1,6 +1,12 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Represents a bootstrapper for the UltimateAuth client, responsible for ensuring that the client is properly initialized and started before use.
+///
public interface IUAuthClientBootstrapper
{
+ ///
+ /// Ensures that the UltimateAuth client is started and ready for use. This method should be called before any operations that require the client to be initialized.
+ ///
Task EnsureStartedAsync(CancellationToken ct = default);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs
index 98618306..b1c7d47b 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs
@@ -3,13 +3,49 @@
namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Defines a client for sending requests to the UltimateAuth server, handling navigation, form submissions, JSON payloads, and transactional operations.
+///
public interface IUAuthRequestClient
{
+ ///
+ /// Navigates to the specified endpoint, optionally submitting form data, and handles the response.
+ ///
+ ///
+ ///
+ ///
+ ///
Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default);
+
+ ///
+ /// Sends a form submission to the specified endpoint and returns the result of the operation.
+ ///
+ ///
+ ///
+ ///
+ ///
Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default);
+
+ ///
+ /// Sends a JSON payload to the specified endpoint and returns the result of the operation.
+ ///
+ ///
+ ///
+ ///
+ ///
Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default);
+
+ ///
+ /// Attempts to perform a transactional operation by first trying the specified endpoint and, if successful, committing the operation to another endpoint. Returns the result of the try operation.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default);
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
index d42e1275..ab7db38c 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs
@@ -1,5 +1,8 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client;
+///
+/// Indicates that the decorated class is a login page component for the UltimateAuth client.
+///
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public sealed class UAuthLoginPageAttribute : Attribute
{
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs
index 717c9378..41ff2612 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs
@@ -3,8 +3,19 @@
namespace CodeBeam.UltimateAuth.Client.Infrastructure;
+///
+/// Provides utility methods for constructing URLs for UltimateAuth endpoints, taking into account multi-tenancy and transport options.
+///
public static class UAuthUrlBuilder
{
+ ///
+ /// Builds a complete URL for an UltimateAuth endpoint based on the provided authority, relative path, and multi-tenant options.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant)
{
var baseAuthority = authority.TrimEnd('/');
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs
index 0b709c79..6a8ab4e2 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs
@@ -1,5 +1,8 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Options for configuring the endpoints of the UAuth client.
+///
public sealed class UAuthClientEndpointOptions
{
///
@@ -7,14 +10,53 @@ public sealed class UAuthClientEndpointOptions
///
public string BasePath { get; set; } = "/auth";
+ ///
+ /// Path for the login endpoint (e.g. /login)
+ ///
public string Login { get; set; } = "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/login";
+
+ ///
+ /// Path for the try login endpoint (e.g. /try-login)
+ ///
public string TryLogin { get; set; } = "/try-login";
+
+ ///
+ /// Path for the logout endpoint (e.g. /logout)
+ ///
public string Logout { get; set; } = "/logout";
+
+ ///
+ /// Path for the refresh endpoint (e.g. /refresh)
+ ///
public string Refresh { get; set; } = "/refresh";
+
+ ///
+ /// Path for the reauth endpoint (e.g. /reauth)
+ ///
public string Reauth { get; set; } = "/reauth";
+
+ ///
+ /// Path for the validate endpoint (e.g. /validate)
+ ///
public string Validate { get; set; } = "/validate";
+
+ ///
+ /// Path for the PKCE authorize endpoint (e.g. /pkce/authorize)
+ ///
public string PkceAuthorize { get; set; } = "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/pkce/authorize";
+
+ ///
+ /// Path for the PKCE try complete endpoint (e.g. /pkce/try-complete)
+ ///
public string PkceTryComplete { get; set; } = "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/pkce/try-complete";
+
+ ///
+ /// Path for the PKCE complete endpoint (e.g. /pkce/complete)
+ ///
public string PkceComplete { get; set; } = "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/pkce/complete";
+
+ ///
+ /// Path for the UAuthHub login endpoint (e.g. /uauthhub/entry)
+ ///
public string HubLoginPath { get; set; } = "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/uauthhub/entry";
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs
index 9cb1d376..db4dc0f2 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs
@@ -1,5 +1,8 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Options for configuring the login flow behavior of the UAuth client.
+///
public sealed class UAuthClientLoginFlowOptions
{
///
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs
index c5d9caf6..ee273691 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs
@@ -2,6 +2,9 @@
namespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Options for multi-tenant support in the UAuth client.
+///
public sealed class UAuthClientMultiTenantOptions
{
///
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs
index 16fa248c..12c4ced0 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs
@@ -2,6 +2,9 @@
namespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Options for configuring the PKCE login flow in the UAuth client.
+///
public sealed class UAuthClientPkceLoginFlowOptions
{
///
@@ -9,6 +12,9 @@ public sealed class UAuthClientPkceLoginFlowOptions
///
public bool Enabled { get; set; } = true;
+ ///
+ /// The URL to redirect to after successful login.
+ ///
public string? ReturnUrl { get; set; }
///
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs
index 3cb6796f..ca523092 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs
@@ -3,8 +3,18 @@
namespace CodeBeam.UltimateAuth.Client.Options;
// TODO: Add ClearCookieOnReauth
+///
+/// Options for reauthentication behavior in the UAuth client.
+///
public sealed class UAuthClientReauthOptions
{
+ ///
+ /// Specifies the behavior to follow when reauthentication is required.
+ ///
public ReauthBehavior Behavior { get; set; } = ReauthBehavior.Redirect;
+
+ ///
+ /// Specifies the path to redirect to when reauthentication is required and the behavior is set to Redirect.
+ ///
public string? RedirectPath { get; set; }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs
index 51bf9632..9b795104 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs
@@ -1,6 +1,12 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Options for handling UAuth state events in the client.
+///
public class UAuthStateEventOptions
{
+ ///
+ /// Gets or sets the handling mode for UAuth state events.
+ ///
public UAuthStateEventHandlingMode HandlingMode { get; set; } = UAuthStateEventHandlingMode.Patch;
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs
index d00b4f2e..2a18d4b8 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs
@@ -2,8 +2,17 @@
namespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Validates the to ensure that all required endpoint paths are specified and not empty.
+///
public sealed class UAuthClientEndpointOptionsValidator : IValidateOptions
{
+ ///
+ /// Validates the specified instance.
+ ///
+ ///
+ ///
+ ///
public ValidateOptionsResult Validate(string? name, UAuthClientOptions options)
{
var e = options.Endpoints;
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs
index 98e2ec3c..f0352dd9 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs
@@ -3,8 +3,17 @@
namespace CodeBeam.UltimateAuth.Client.Options;
+///
+/// Validates the to ensure that the configuration is consistent and valid.
+///
public sealed class UAuthClientOptionsValidator : IValidateOptions
{
+ ///
+ /// Validates the provided instance.
+ ///
+ ///
+ ///
+ ///
public ValidateOptionsResult Validate(string? name, UAuthClientOptions options)
{
if (options.ClientProfile == UAuthClientProfile.NotSpecified && options.AutoDetectClientProfile == false)
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs
index d240224a..2fabb239 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs
@@ -1,6 +1,12 @@
ο»Ώnamespace CodeBeam.UltimateAuth.Client.Runtime;
+///
+/// Provides information about the product using the UltimateAuth client.
+///
public interface IUAuthClientProductInfoProvider
{
+ ///
+ /// Gets the product information for the UltimateAuth client.
+ ///
UAuthClientProductInfo Get();
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs
index 771c92c5..ffc919af 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs
@@ -3,20 +3,61 @@
namespace CodeBeam.UltimateAuth.Client.Runtime;
+///
+/// Represents product information for the UltimateAuth client, including versioning, client profile, and runtime details.
+///
public sealed class UAuthClientProductInfo
{
+ ///
+ /// Gets the name of the product. This is a read-only property initialized to "UltimateAuth Client".
+ ///
public string ProductName { get; init; } = "UltimateAuth Client";
+
+ ///
+ /// Gets the version of the product. This is a required property that must be initialized with a valid version string.
+ ///
public string Version { get; init; } = default!;
+
+ ///
+ /// Gets the informational version of the product. This is an optional property that can be initialized with a version string for informational purposes.
+ ///
public string? InformationalVersion { get; init; }
+
+ ///
+ /// Gets the client profile associated with the UltimateAuth client. This is a required property that must be initialized with a valid UAuthClientProfile value.
+ ///
public UAuthClientProfile ClientProfile { get; init; } = default!;
+
+ ///
+ /// Gets the timestamp indicating when the UltimateAuth client started. This is a required property that must be initialized with a valid DateTimeOffset value.
+ ///
public DateTimeOffset StartedAt { get; init; }
+
+ ///
+ /// Gets the runtime identifier for the UltimateAuth client. This is a read-only property initialized with a new GUID in string format, which uniquely identifies the runtime instance.
+ ///
public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n");
+
+ ///
+ /// Gets a value indicating whether auto-refresh is enabled for the UltimateAuth client. This is a required property that must be initialized with a boolean value.
+ ///
public bool AutoRefreshEnabled { get; init; }
+
+ ///
+ /// Gets the refresh interval for the UltimateAuth client. This is an optional property that can be initialized with a TimeSpan value indicating how often the client should refresh its state. If not set, the client may use a default refresh interval.
+ ///
public TimeSpan? RefreshInterval { get; init; }
+
+ ///
+ /// Gets the reauthentication behavior for the UltimateAuth client. This is a required property that must be initialized with a valid ReauthBehavior value, which determines how the client handles reauthentication scenarios.
+ ///
public ReauthBehavior ReauthBehavior { get; init; }
+ ///
+ /// Gets the framework description for the UltimateAuth client. This is a required property that must be initialized with a string value describing the framework in which the client is running (e.g., ".NET 6.0", ".NET 7.0").
+ ///
public string FrameworkDescription { get; init; } = default!;
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs
index cf9bb88e..f103469a 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs
@@ -7,6 +7,7 @@
using CodeBeam.UltimateAuth.Client.Infrastructure;
using CodeBeam.UltimateAuth.Client.Options;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
using CodeBeam.UltimateAuth.Users.Contracts;
@@ -279,7 +280,7 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ
if (!string.IsNullOrWhiteSpace(request.ReturnUrl))
{
- payload["return_url"] = request.ReturnUrl;
+ payload[UAuthConstants.Form.ReturnUrl] = request.ReturnUrl;
}
switch (mode)
@@ -330,7 +331,7 @@ public async Task CompletePkceLoginAsync(PkceCompleteRequest request)
{
["authorization_code"] = request.AuthorizationCode,
["code_verifier"] = request.CodeVerifier,
- ["return_url"] = request.ReturnUrl ?? string.Empty,
+ [UAuthConstants.Form.ReturnUrl] = request.ReturnUrl ?? string.Empty,
["Identifier"] = request.Identifier ?? string.Empty,
["Secret"] = request.Secret ?? string.Empty,
@@ -419,7 +420,7 @@ private IDictionary BuildPayload(LoginRequest request, string? r
if (!string.IsNullOrWhiteSpace(resolvedReturnUrl))
{
- payload["return_url"] = resolvedReturnUrl;
+ payload[UAuthConstants.Form.ReturnUrl] = resolvedReturnUrl;
}
return payload;
@@ -436,7 +437,7 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi
{
["authorization_code"] = authorizationCode,
["code_verifier"] = codeVerifier,
- ["return_url"] = returnUrl,
+ [UAuthConstants.Form.ReturnUrl] = returnUrl,
["client_profile"] = _options.ClientProfile.ToString(),
["device"] = deviceEncoded
};
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs
index 00bbef97..83c6f847 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs
@@ -32,6 +32,24 @@ public async Task ExistsAsync(CredentialKey key, CancellationToken ct = de
public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
+ if (credential.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
+ var exists = await DbSet
+ .AsNoTracking()
+ .AnyAsync(
+ x =>
+ x.Tenant == _tenant &&
+ x.UserKey == credential.UserKey &&
+ x.DeletedAt == null,
+ ct);
+
+ if (exists)
+ throw new UAuthConflictException("password_credential_exists");
+
+
var entity = credential.ToProjection();
DbSet.Add(entity);
@@ -73,6 +91,8 @@ public async Task SaveAsync(PasswordCredential credential, long expectedVersion,
public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
var entity = await DbSet
.SingleOrDefaultAsync(x =>
x.Id == key.Id &&
@@ -95,6 +115,8 @@ public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long
public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
var entity = await DbSet
.SingleOrDefaultAsync(x =>
x.Id == key.Id &&
@@ -123,6 +145,8 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod
public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default)
{
+ ct.ThrowIfCancellationRequested();
+
var entities = await DbSet
.AsNoTracking()
.Where(x =>
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs
index 4d524d37..4cbd31b2 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs
@@ -19,6 +19,8 @@ public InMemoryPasswordCredentialStore(TenantExecutionContext tenant) : base(ten
protected override void BeforeAdd(PasswordCredential entity)
{
+ base.BeforeAdd(entity);
+
var exists = TenantValues()
.Any(x =>
x.Tenant == entity.Tenant &&
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs
index 156a21be..35a00498 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs
@@ -1,3 +1,5 @@
ο»Ώusing System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.Reference")]
+[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
index fd88eedc..d5e583c6 100644
--- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
+++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs
@@ -74,6 +74,7 @@ public PasswordCredential Snapshot()
Metadata = Metadata,
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
+ DeletedAt = DeletedAt,
Version = Version
};
}
diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs
index f43531e4..eb7edb5c 100644
--- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs
+++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs
@@ -43,6 +43,18 @@ protected IReadOnlyList TenantValues()
.AsReadOnly();
}
+ protected override void ValidateAdd(TEntity entity)
+ {
+ EnsureTenant(entity);
+ base.ValidateAdd(entity);
+ }
+
+ protected override void ValidateSave(TEntity entity, long expectedVersion)
+ {
+ EnsureTenant(entity);
+ base.ValidateSave(entity, expectedVersion);
+ }
+
private void EnsureTenant(TEntity entity)
{
if (!_tenant.IsGlobal && entity.Tenant != _tenant.Tenant)
diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs
index 8d705d32..fc2df442 100644
--- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs
+++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs
@@ -16,6 +16,8 @@ public abstract class InMemoryVersionedStore : IVersionedStore GetAsync(TKey key, CancellationToken ct = default)
{
@@ -38,6 +40,11 @@ public virtual Task AddAsync(TEntity entity, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ ValidateAdd(entity);
+
+ if (entity.Version != 0)
+ throw new InvalidOperationException($"New {typeof(TEntity).Name} must have version 0.");
+
var key = GetKey(entity);
var snapshot = Snapshot(entity);
@@ -53,6 +60,8 @@ public virtual Task SaveAsync(TEntity entity, long expectedVersion, Cancellation
{
ct.ThrowIfCancellationRequested();
+ ValidateSave(entity, expectedVersion);
+
var key = GetKey(entity);
if (!_store.TryGetValue(key, out var current))
diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs
index 5fd8819f..79c3c5d3 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs
@@ -53,16 +53,27 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain)
public static void UpdateProjection(this UAuthSessionChain source, SessionChainProjection target)
{
- DeviceId.TryCreate(source.Device.DeviceId?.Value, out var deviceId);
+ if (source.Device.DeviceId is not DeviceId deviceId)
+ throw new ArgumentException("Device id required.");
target.ActiveSessionId = source.ActiveSessionId;
target.RevokedAt = source.RevokedAt;
+
target.DeviceId = deviceId;
target.Device = source.Device;
+
target.ClaimsSnapshot = source.ClaimsSnapshot;
- target.SecurityVersionAtCreation = source.SecurityVersionAtCreation;
+
+ target.SecurityVersionAtCreation =
+ source.SecurityVersionAtCreation;
+
target.LastSeenAt = source.LastSeenAt;
target.AbsoluteExpiresAt = source.AbsoluteExpiresAt;
- // Version store-owned
+
+ target.RotationCount = source.RotationCount;
+ target.TouchCount = source.TouchCount;
+
+ // Version intentionally omitted:
+ // optimistic concurrency/version is store-owned.
}
}
diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs
index 99824242..bcb56759 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs
@@ -132,21 +132,31 @@ public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, C
projection.Version++;
}
- public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default)
+ public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ if (session.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (!_inExecution)
throw new InvalidOperationException("Must be called inside ExecuteAsync");
- var projection = session.ToProjection();
-
if (session.Version != 0)
throw new InvalidOperationException("New session must have version 0.");
- DbSetSession.Add(projection);
+ var exists = await _db.Set()
+ .AnyAsync(
+ x => x.Tenant == _tenant &&
+ x.SessionId == session.SessionId,
+ ct);
- return Task.CompletedTask;
+ if (exists)
+ throw new UAuthConcurrencyException(
+ "session_already_exists");
+
+ var projection = session.ToProjection();
+ DbSetSession.Add(projection);
}
public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default)
@@ -165,6 +175,19 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs
domain.UpdateProjection(projection);
projection.Version++;
+ var chain = await DbSetChain
+ .SingleOrDefaultAsync(
+ x => x.Tenant == _tenant &&
+ x.ChainId == projection.ChainId,
+ ct);
+
+ if (chain?.ActiveSessionId == sessionId)
+ {
+ chain.ActiveSessionId = null;
+ chain.LastSeenAt = at;
+ chain.Version++;
+ }
+
return true;
}
@@ -314,22 +337,38 @@ public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion,
projection.Version++;
}
- public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default)
+ public async Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ if (chain.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (!_inExecution)
throw new InvalidOperationException("Must be called inside ExecuteAsync");
if (chain.Version != 0)
throw new InvalidOperationException("New chain must have version 0.");
- var projection = chain.ToProjection();
+ var exists =
+ DbSetChain.Local.Any(x =>
+ x.Tenant == _tenant &&
+ x.ChainId == chain.ChainId);
- DbSetChain.Add(projection);
- _db.Entry(projection).State = EntityState.Added;
+ if (!exists)
+ {
+ exists = await DbSetChain
+ .AsNoTracking()
+ .AnyAsync(
+ x => x.Tenant == _tenant &&
+ x.ChainId == chain.ChainId,
+ ct);
+ }
- return Task.CompletedTask;
+ if (exists)
+ throw new UAuthConcurrencyException("chain_already_exists");
+
+ DbSetChain.Add(chain.ToProjection());
}
public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default)
@@ -475,6 +514,9 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can
{
ct.ThrowIfCancellationRequested();
+ if (root.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (!_inExecution)
throw new InvalidOperationException("Must be called inside ExecuteAsync");
@@ -494,21 +536,40 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can
projection.Version++;
}
- public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default)
+ public async Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ if (root.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (!_inExecution)
throw new InvalidOperationException("Must be called inside ExecuteAsync");
if (root.Version != 0)
throw new InvalidOperationException("New root must have version 0.");
+ var exists = DbSetRoot.Local.Any(x =>
+ x.Tenant == _tenant &&
+ x.UserKey == root.UserKey);
+
+ if (!exists)
+ {
+ exists = await DbSetRoot
+ .AsNoTracking()
+ .AnyAsync(
+ x =>
+ x.Tenant == _tenant &&
+ x.UserKey == root.UserKey,
+ ct);
+ }
+
+ if (exists)
+ throw new UAuthConcurrencyException("root_already_exists");
+
var projection = root.ToProjection();
DbSetRoot.Add(projection);
-
- return Task.CompletedTask;
}
public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default)
@@ -604,6 +665,21 @@ public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken
if (projection is null)
return;
+ var chain = await DbSetChain
+ .SingleOrDefaultAsync(
+ x => x.Tenant == _tenant &&
+ x.ChainId == projection.ChainId,
+ ct);
+
+ if (chain?.ActiveSessionId == sessionId)
+ {
+ chain.ActiveSessionId = null;
+
+ // Remove has no DateTimeOffset parameter.
+ // Don't introduce DateTimeOffset.UtcNow here merely to update LastSeenAt.
+ chain.Version++;
+ }
+
DbSetSession.Remove(projection);
}
diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs
index c21a91d6..c081e412 100644
--- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs
+++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs
@@ -73,6 +73,9 @@ public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = defa
lock (_lock)
{
+ if (session.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (_sessions.ContainsKey(session.SessionId))
throw new UAuthConcurrencyException("session_already_exists");
@@ -203,6 +206,9 @@ public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = def
{
ct.ThrowIfCancellationRequested();
+ if (chain.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
lock (_lock)
{
if (_chains.ContainsKey(chain.ChainId))
@@ -285,6 +291,9 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Cancellat
{
ct.ThrowIfCancellationRequested();
+ if (root.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
if (!_roots.TryGetValue((_tenant, root.UserKey), out var current))
throw new UAuthNotFoundException("root_not_found");
@@ -299,6 +308,9 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul
{
ct.ThrowIfCancellationRequested();
+ if (root.Tenant != _tenant)
+ throw new InvalidOperationException("Tenant mismatch.");
+
lock (_lock)
{
if (_roots.ContainsKey((_tenant, root.UserKey)))
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs
index 14e4de4f..2f87aab7 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs
@@ -145,6 +145,9 @@ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
if (entity.Version != 0)
throw new UAuthValidationException("New identifier must have version 0.");
@@ -176,6 +179,9 @@ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, Cancell
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
using var tx = await _db.Database.BeginTransactionAsync(ct);
if (entity.IsPrimary)
@@ -322,11 +328,26 @@ public async Task> QueryAsync(UserIdentifierQuery qu
? baseQuery.OrderByDescending(x => x.CreatedAt)
: baseQuery.OrderBy(x => x.CreatedAt),
+ nameof(UserIdentifier.UpdatedAt) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.UpdatedAt)
+ : baseQuery.OrderBy(x => x.UpdatedAt),
+
+ nameof(UserIdentifier.DeletedAt) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.DeletedAt)
+ : baseQuery.OrderBy(x => x.DeletedAt),
+
nameof(UserIdentifier.Value) =>
query.Descending
? baseQuery.OrderByDescending(x => x.Value)
: baseQuery.OrderBy(x => x.Value),
+ nameof(UserIdentifier.NormalizedValue) =>
+ query.Descending
+ ? baseQuery.OrderByDescending(x => x.NormalizedValue)
+ : baseQuery.OrderBy(x => x.NormalizedValue),
+
_ => baseQuery.OrderBy(x => x.CreatedAt)
};
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs
index 9994d412..bd617ef4 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs
@@ -48,9 +48,23 @@ public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
if (entity.Version != 0)
throw new InvalidOperationException("New lifecycle must have version 0.");
+ var exists = await DbSet
+ .AsNoTracking()
+ .AnyAsync(
+ x =>
+ x.Tenant == _tenant &&
+ x.UserKey == entity.UserKey,
+ ct);
+
+ if (exists)
+ throw new UAuthConflictException("user_lifecycle_exists");
+
var projection = entity.ToProjection();
DbSet.Add(projection);
@@ -62,6 +76,9 @@ public async Task SaveAsync(UserLifecycle entity, long expectedVersion, Cancella
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
var existing = await DbSet
.SingleOrDefaultAsync(x =>
x.Tenant == _tenant &&
@@ -133,6 +150,12 @@ public async Task> QueryAsync(UserLifecycleQuery quer
nameof(UserLifecycle.CreatedAt) =>
query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt),
+ nameof(UserLifecycle.UpdatedAt) =>
+ query.Descending ? baseQuery.OrderByDescending(x => x.UpdatedAt) : baseQuery.OrderBy(x => x.UpdatedAt),
+
+ nameof(UserLifecycle.DeletedAt) =>
+ query.Descending ? baseQuery.OrderByDescending(x => x.DeletedAt) : baseQuery.OrderBy(x => x.DeletedAt),
+
nameof(UserLifecycle.Status) =>
query.Descending ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status),
diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs
index dbbe32fd..32bef99f 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs
@@ -53,6 +53,9 @@ public async Task AddAsync(UserProfile entity, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
var projection = entity.ToProjection();
if (entity.Version != 0)
@@ -77,6 +80,9 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati
{
ct.ThrowIfCancellationRequested();
+ if (entity.Tenant != _tenant)
+ throw new UAuthConflictException("tenant_mismatch");
+
var existing = await DbSet
.SingleOrDefaultAsync(x =>
x.Tenant == _tenant &&
@@ -206,9 +212,14 @@ public async Task> GetAllProfilesByUserAsync(UserKey
var projections = await DbSet
.AsNoTracking()
- .Where(x => x.Tenant == _tenant)
- .Where(x => x.UserKey == userKey)
+ .Where(x =>
+ x.Tenant == _tenant &&
+ x.UserKey == userKey &&
+ x.DeletedAt == null)
.ToListAsync(ct);
- return projections.Select(x => x.ToDomain()).ToList();
+
+ return projections
+ .Select(x => x.ToDomain())
+ .ToList();
}
}
diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
index 27ee484a..252fe6d7 100644
--- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
+++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs
@@ -243,13 +243,16 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque
foreach (var profile in profiles)
{
var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey);
- await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt);
+ await profileStore.DeleteAsync(key, profile.Version, request.Mode, now, innerCt);
}
foreach (var integration in _integrations)
{
await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt);
}
+
+ var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant);
+ await sessionStore.RevokeAllChainsAsync(targetUserKey, now, innerCt);
});
await _accessOrchestrator.ExecuteAsync(context, command, ct);
@@ -572,13 +575,6 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde
throw new UAuthIdentifierValidationException("username_change_not_allowed");
}
- var validationDto = identifier.ToDto();
- var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt);
- if (validationResult.IsValid != true)
- {
- throw new UAuthValidationException(string.Join(", ", validationResult.Errors));
- }
-
var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue);
if (!normalized.IsValid)
throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid");
@@ -586,6 +582,17 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde
if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal))
throw new UAuthIdentifierValidationException("identifier_value_unchanged");
+ var validationDto = identifier.ToDto();
+ validationDto.Value = request.NewValue;
+ validationDto.NormalizedValue = normalized.Normalized;
+
+ var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt);
+
+ if (!validationResult.IsValid)
+ {
+ throw new UAuthValidationException(string.Join(", ", validationResult.Errors));
+ }
+
var withinUserResult = await identifierStore.ExistsAsync(
new IdentifierExistenceQuery(
identifier.Type,
diff --git a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj
index 15919d0a..cdce659b 100644
--- a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj
+++ b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj
@@ -9,8 +9,8 @@
-
-
+
+
diff --git a/tests/.gitkeep b/tests/.gitkeep
deleted file mode 100644
index 5f282702..00000000
--- a/tests/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-ο»Ώ
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs
new file mode 100644
index 00000000..e3fc6db3
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs
@@ -0,0 +1,584 @@
+ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Security;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts;
+
+public abstract class AuthenticationSecurityStateStoreContractTests
+{
+ protected abstract Task CreateDatabaseAsync();
+
+ protected virtual TenantKey Tenant => TenantKeys.Single;
+
+ protected static readonly DateTimeOffset Now =
+ new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ // ---------------------------------------------------------
+ // Add / Get
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task AddAsync_WhenValid_PersistsState()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var state = AuthenticationSecurityState.CreateAccount(Tenant, user);
+
+ await store.AddAsync(state);
+
+ var result = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(state.Id);
+ result.Tenant.Should().Be(Tenant);
+ result.UserKey.Should().Be(user);
+ result.Scope.Should().Be(state.Scope);
+ result.CredentialType.Should().Be(state.CredentialType);
+ result.SecurityVersion.Should().Be(state.SecurityVersion);
+ }
+
+ [Fact]
+ public async Task AddAsync_SameUserWithAccountAndFactorStates_IsAllowed()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var account = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ user);
+
+ var factor = AuthenticationSecurityState.CreateFactor(
+ Tenant,
+ user,
+ CredentialType.Password);
+
+ await store.AddAsync(account);
+ await store.AddAsync(factor);
+
+ var accountResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var factorResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ accountResult.Should().NotBeNull();
+ factorResult.Should().NotBeNull();
+
+ accountResult!.Id.Should().Be(account.Id);
+ factorResult!.Id.Should().Be(factor.Id);
+ }
+
+ [Fact]
+ public async Task AddAsync_WhenSameLogicalStateAlreadyExists_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var first = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ user);
+
+ var second = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ user);
+
+ await store.AddAsync(first);
+
+ var act = () => store.AddAsync(second);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task AddAsync_WhenStateBelongsToDifferentTenant_IsRejected()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var store = db.CreateStore(tenantA);
+
+ var state = AuthenticationSecurityState.CreateAccount(
+ tenantB,
+ UserKey.New());
+
+ var act = () => store.AddAsync(state);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetAsync_WhenStateDoesNotExist_ReturnsNull()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var result = await store.GetAsync(
+ UserKey.New(),
+ AuthenticationSecurityScope.Account,
+ null);
+
+ result.Should().BeNull();
+ }
+
+ // ---------------------------------------------------------
+ // Logical key
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task AddAsync_SameUserWithDifferentScope_IsAllowed()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var first = AuthenticationSecurityState.CreateAccount(Tenant, user);
+
+ var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password);
+
+ await store.AddAsync(first);
+ await store.AddAsync(second);
+
+ var firstResult = await store.GetAsync(
+ user,
+ first.Scope,
+ first.CredentialType);
+
+ var secondResult = await store.GetAsync(
+ user,
+ second.Scope,
+ second.CredentialType);
+
+ firstResult.Should().NotBeNull();
+ secondResult.Should().NotBeNull();
+
+ firstResult!.Id.Should().Be(first.Id);
+ secondResult!.Id.Should().Be(second.Id);
+ }
+
+ [Fact]
+ public async Task AddAsync_SameUserAndScopeWithDifferentCredentialType_IsAllowed()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var first = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password);
+
+ var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey);
+
+ await store.AddAsync(first);
+ await store.AddAsync(second);
+
+ var password = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ var passkey = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Passkey);
+
+ password.Should().NotBeNull();
+ passkey.Should().NotBeNull();
+
+ password!.Id.Should().Be(first.Id);
+ passkey!.Id.Should().Be(second.Id);
+ }
+
+ // ---------------------------------------------------------
+ // Update
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var original = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ user);
+
+ await store.AddAsync(original);
+
+ var updated = original.RegisterFailure(
+ Now,
+ threshold: 3,
+ lockoutDuration: TimeSpan.FromMinutes(15));
+
+ await store.UpdateAsync(
+ updated,
+ expectedVersion: original.SecurityVersion);
+
+ var result = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ result.Should().NotBeNull();
+
+ result!.Id.Should().Be(original.Id);
+ result.SecurityVersion.Should().Be(1);
+ result.FailedAttempts.Should().Be(1);
+ result.LastFailedAt.Should().Be(Now);
+ result.LockedUntil.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task UpdateAsync_WhenExpectedVersionIsStale_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var original = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ UserKey.New());
+
+ await store.AddAsync(original);
+
+ var updated = original.RegisterFailure(
+ Now,
+ threshold: 3,
+ lockoutDuration: TimeSpan.FromMinutes(15));
+
+ var act = () => store.UpdateAsync(
+ updated,
+ expectedVersion: updated.SecurityVersion);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task UpdateAsync_WhenResetBegins_PersistsResetState()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var original = AuthenticationSecurityState.CreateFactor(
+ Tenant,
+ user,
+ CredentialType.Password);
+
+ await store.AddAsync(original);
+
+ var updated = original.BeginReset(
+ "hashed-reset-token",
+ Now,
+ TimeSpan.FromMinutes(30));
+
+ await store.UpdateAsync(
+ updated,
+ original.SecurityVersion);
+
+ var result = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ result.Should().NotBeNull();
+
+ result!.ResetRequestedAt.Should().Be(Now);
+ result.ResetExpiresAt.Should().Be(Now.AddMinutes(30));
+ result.ResetConsumedAt.Should().BeNull();
+ result.ResetTokenHash.Should().Be("hashed-reset-token");
+ result.ResetAttempts.Should().Be(0);
+ result.SecurityVersion.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_WhenStateDoesNotExist_ThrowsNotFound()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var original = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ UserKey.New());
+
+ var updated = original.RequireReauthentication();
+
+ var act = () => store.UpdateAsync(
+ updated,
+ original.SecurityVersion);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ // ---------------------------------------------------------
+ // Delete
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task DeleteAsync_WhenStateExists_RemovesState()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var state = AuthenticationSecurityState.CreateAccount(Tenant, user);
+
+ await store.AddAsync(state);
+
+ await store.DeleteAsync(
+ user,
+ state.Scope,
+ state.CredentialType);
+
+ var result = await store.GetAsync(
+ user,
+ state.Scope,
+ state.CredentialType);
+
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DeleteAsync_AccountState_DoesNotDeleteFactorState()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var account = AuthenticationSecurityState.CreateAccount(
+ Tenant,
+ user);
+
+ var factor = AuthenticationSecurityState.CreateFactor(
+ Tenant,
+ user,
+ CredentialType.Password);
+
+ await store.AddAsync(account);
+ await store.AddAsync(factor);
+
+ await store.DeleteAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var accountResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var factorResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ accountResult.Should().BeNull();
+ factorResult.Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task DeleteAsync_WhenStateDoesNotExist_IsIdempotent()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var act = () => store.DeleteAsync(
+ UserKey.New(),
+ AuthenticationSecurityScope.Account,
+ null);
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task DeleteAsync_RemovesOnlyExactLogicalState()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var password = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password);
+ var passkey = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey);
+
+ await store.AddAsync(password);
+ await store.AddAsync(passkey);
+
+ await store.DeleteAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ var passwordResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Password);
+
+ var passkeyResult = await store.GetAsync(
+ user,
+ AuthenticationSecurityScope.Factor,
+ CredentialType.Passkey);
+
+ passwordResult.Should().BeNull();
+ passkeyResult.Should().NotBeNull();
+ }
+
+ // ---------------------------------------------------------
+ // Tenant isolation
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task GetAsync_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var user = UserKey.New();
+
+ var state = AuthenticationSecurityState.CreateAccount(
+ tenantA,
+ user);
+
+ await storeA.AddAsync(state);
+
+ var fromA = await storeA.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var fromB = await storeB.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ fromA.Should().NotBeNull();
+ fromA!.Tenant.Should().Be(tenantA);
+
+ fromB.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DeleteAsync_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var user = UserKey.New();
+
+ var stateA = AuthenticationSecurityState.CreateAccount(
+ tenantA,
+ user);
+
+ var stateB = AuthenticationSecurityState.CreateAccount(
+ tenantB,
+ user);
+
+ await storeA.AddAsync(stateA);
+ await storeB.AddAsync(stateB);
+
+ await storeA.DeleteAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var fromA = await storeA.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ var fromB = await storeB.GetAsync(
+ user,
+ AuthenticationSecurityScope.Account,
+ null);
+
+ fromA.Should().BeNull();
+
+ fromB.Should().NotBeNull();
+ fromB!.Id.Should().Be(stateB.Id);
+ fromB.Tenant.Should().Be(tenantB);
+ }
+
+ // ---------------------------------------------------------
+ // Cancellation
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task Operations_WhenAlreadyCancelled_ThrowOperationCanceledException()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var state = AuthenticationSecurityState.CreateAccount(Tenant, UserKey.New());
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await FluentActions
+ .Invoking(() => store.GetAsync(
+ state.UserKey,
+ state.Scope,
+ state.CredentialType,
+ cts.Token))
+ .Should()
+ .ThrowAsync();
+
+ await FluentActions
+ .Invoking(() => store.AddAsync(state, cts.Token))
+ .Should()
+ .ThrowAsync();
+
+ await FluentActions
+ .Invoking(() => store.UpdateAsync(
+ state,
+ state.SecurityVersion,
+ cts.Token))
+ .Should()
+ .ThrowAsync();
+
+ await FluentActions
+ .Invoking(() => store.DeleteAsync(
+ state.UserKey,
+ state.Scope,
+ state.CredentialType,
+ cts.Token))
+ .Should()
+ .ThrowAsync();
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs
new file mode 100644
index 00000000..003f5e51
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs
@@ -0,0 +1,78 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts;
+
+public sealed class EfCoreAuthenticationSecurityStateStoreContractTests : AuthenticationSecurityStateStoreContractTests
+{
+ protected override async Task
+ CreateDatabaseAsync()
+ {
+ var database = new Database();
+
+ await database.InitializeAsync();
+
+ return database;
+ }
+
+ private sealed class Database
+ : IAuthenticationSecurityStateStoreTestDatabase
+ {
+ private readonly SqliteConnection _connection;
+
+ private readonly DbContextOptions
+ _options;
+
+ private readonly List
+ _contexts = [];
+
+ public Database()
+ {
+ _connection =
+ new SqliteConnection("Data Source=:memory:");
+
+ _options =
+ new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _connection.OpenAsync();
+
+ await using var db =
+ new UAuthAuthenticationDbContext(_options);
+
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public IAuthenticationSecurityStateStore CreateStore(
+ TenantKey tenant)
+ {
+ var db =
+ new UAuthAuthenticationDbContext(_options);
+
+ _contexts.Add(db);
+
+ return new EfCoreAuthenticationSecurityStateStore<
+ UAuthAuthenticationDbContext>(
+ db,
+ new TenantExecutionContext(tenant));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var context in _contexts)
+ await context.DisposeAsync();
+
+ await _connection.DisposeAsync();
+ }
+ }
+
+ // AynΔ± CreateState / MutateState / AssertMutationPersisted
+ // implementation'Δ±.
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs
new file mode 100644
index 00000000..e7f61ff8
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs
@@ -0,0 +1,9 @@
+ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts;
+
+public interface IAuthenticationSecurityStateStoreTestDatabase : IAsyncDisposable
+{
+ IAuthenticationSecurityStateStore CreateStore(TenantKey tenant);
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs
new file mode 100644
index 00000000..ab537943
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs
@@ -0,0 +1,40 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authentication.InMemory;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts;
+
+public sealed class InMemoryAuthenticationSecurityStateStoreContractTests : AuthenticationSecurityStateStoreContractTests
+{
+ protected override Task
+ CreateDatabaseAsync()
+ {
+ return Task.FromResult(
+ new Database());
+ }
+
+ private sealed class Database
+ : IAuthenticationSecurityStateStoreTestDatabase
+ {
+ private readonly Dictionary<
+ TenantKey,
+ IAuthenticationSecurityStateStore> _stores = [];
+
+ public IAuthenticationSecurityStateStore CreateStore(
+ TenantKey tenant)
+ {
+ if (_stores.TryGetValue(tenant, out var store))
+ return store;
+
+ store = new InMemoryAuthenticationSecurityStateStore(
+ new TenantExecutionContext(tenant));
+
+ _stores.Add(tenant, store);
+
+ return store;
+ }
+
+ public ValueTask DisposeAsync()
+ => ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs
new file mode 100644
index 00000000..655e92f7
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs
@@ -0,0 +1,824 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Authorization.Reference;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Moq;
+using System.Text.Json;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit;
+
+public sealed class AuthorizationEndpointHandlerTests
+{
+ // =========================================================
+ // Check
+ // =========================================================
+
+ [Fact]
+ public async Task CheckAsync_WhenUnauthenticated_ReturnsUnauthorized()
+ {
+ var f = new Fixture(isAuthenticated: false);
+ var ctx = f.Http();
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status401Unauthorized);
+
+ f.AccessContextFactory.VerifyNoOtherCalls();
+ f.Authorization.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task CheckAsync_WhenResourceIsMissing_ReturnsBadRequest()
+ {
+ var f = new Fixture();
+
+ var ctx = f.Json(new AuthorizationCheckRequest
+ {
+ Action = "orders.read",
+ Resource = ""
+ });
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status400BadRequest);
+
+ f.AccessContextFactory.VerifyNoOtherCalls();
+ f.Authorization.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task CheckAsync_WhenActionIsMissing_ReturnsBadRequest()
+ {
+ var f = new Fixture();
+
+ var ctx = f.Json(new AuthorizationCheckRequest
+ {
+ Action = "",
+ Resource = "orders"
+ });
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status400BadRequest);
+
+ f.AccessContextFactory.VerifyNoOtherCalls();
+ f.Authorization.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task CheckAsync_WhenAllowed_CreatesExpectedAccessContextAndReturnsOk()
+ {
+ var f = new Fixture();
+ var accessContext = f.AccessContext("orders.read");
+
+ var ctx = f.Json(new AuthorizationCheckRequest
+ {
+ Action = "orders.read",
+ Resource = "orders",
+ ResourceId = "order-123"
+ });
+
+ f.SetupAccessContext(
+ "orders.read",
+ "orders",
+ "order-123",
+ accessContext);
+
+ var authorizationResult = AuthorizationResult.Allow();
+
+ f.Authorization
+ .Setup(x => x.AuthorizeAsync(
+ accessContext,
+ ctx.RequestAborted))
+ .ReturnsAsync(authorizationResult);
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.Authorization.Verify(x => x.AuthorizeAsync(
+ accessContext,
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task CheckAsync_WhenDenied_ReturnsForbidden()
+ {
+ var f = new Fixture();
+ var accessContext = f.AccessContext("orders.delete");
+
+ var ctx = f.Json(new AuthorizationCheckRequest
+ {
+ Action = "orders.delete",
+ Resource = "orders",
+ ResourceId = "order-123"
+ });
+
+ f.SetupAccessContext(
+ "orders.delete",
+ "orders",
+ "order-123",
+ accessContext);
+
+ f.Authorization
+ .Setup(x => x.AuthorizeAsync(
+ accessContext,
+ ctx.RequestAborted))
+ .ReturnsAsync(AuthorizationResult.Deny("access_denied"));
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ result.Should().BeOfType();
+
+ f.Authorization.Verify(x => x.AuthorizeAsync(
+ accessContext,
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428()
+ {
+ var f = new Fixture();
+ var accessContext = f.AccessContext("orders.delete");
+
+ var ctx = f.Json(new AuthorizationCheckRequest
+ {
+ Action = "orders.delete",
+ Resource = "orders",
+ ResourceId = "order-123"
+ });
+
+ f.SetupAccessContext(
+ "orders.delete",
+ "orders",
+ "order-123",
+ accessContext);
+
+ f.Authorization
+ .Setup(x => x.AuthorizeAsync(
+ accessContext,
+ ctx.RequestAborted))
+ .ReturnsAsync(AuthorizationResult.ReauthRequired());
+
+ var result = await f.Sut.CheckAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status428PreconditionRequired);
+ }
+
+ // =========================================================
+ // Get My Roles
+ // =========================================================
+
+ [Fact]
+ public async Task GetMyRolesAsync_WhenUnauthenticated_ReturnsUnauthorized()
+ {
+ var f = new Fixture(isAuthenticated: false);
+
+ var result = await f.Sut.GetMyRolesAsync(f.Http());
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status401Unauthorized);
+
+ f.UserRoles.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task GetMyRolesAsync_WhenAuthenticated_UsesCurrentUser()
+ {
+ var f = new Fixture();
+ var query = new RoleQuery
+ {
+ PageNumber = 2,
+ PageSize = 20
+ };
+
+ var ctx = f.Json(query);
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.GetSelf);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.GetSelf,
+ "authorization.roles",
+ f.UserKey.Value,
+ accessContext);
+
+ var expected = EmptyRoles();
+
+ f.UserRoles
+ .Setup(x => x.GetRolesAsync(
+ accessContext,
+ f.UserKey,
+ It.Is(q =>
+ q.PageNumber == 2 &&
+ q.PageSize == 20),
+ ctx.RequestAborted))
+ .ReturnsAsync(expected);
+
+ var result = await f.Sut.GetMyRolesAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.UserRoles.Verify(x => x.GetRolesAsync(
+ accessContext,
+ f.UserKey,
+ It.IsAny(),
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Get User Roles
+ // =========================================================
+
+ [Fact]
+ public async Task GetUserRolesAsync_WhenAuthenticated_UsesTargetUserAndAdminAction()
+ {
+ var f = new Fixture();
+ var target = UserKey.New();
+
+ var ctx = f.Json(new RoleQuery());
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.GetAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.GetAdmin,
+ "authorization.roles",
+ target.Value,
+ accessContext);
+
+ f.UserRoles
+ .Setup(x => x.GetRolesAsync(
+ accessContext,
+ target,
+ It.IsAny(),
+ ctx.RequestAborted))
+ .ReturnsAsync(EmptyRoles());
+
+ var result = await f.Sut.GetUserRolesAsync(
+ target,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.UserRoles.Verify(x => x.GetRolesAsync(
+ accessContext,
+ target,
+ It.IsAny(),
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Assign
+ // =========================================================
+
+ [Fact]
+ public async Task AssignRoleAsync_WhenUnauthenticated_ReturnsUnauthorized()
+ {
+ var f = new Fixture(isAuthenticated: false);
+ var target = UserKey.New();
+
+ var result = await f.Sut.AssignRoleAsync(
+ target,
+ f.Http());
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status401Unauthorized);
+
+ f.UserRoles.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task AssignRoleAsync_WhenAuthenticated_UsesTargetUserAndRoleName()
+ {
+ var f = new Fixture();
+ var target = UserKey.New();
+
+ var ctx = f.Json(new AssignRoleRequest
+ {
+ UserKey = target,
+ RoleName = "Administrators"
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.AssignAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.AssignAdmin,
+ "authorization.roles",
+ target.Value,
+ accessContext);
+
+ f.UserRoles
+ .Setup(x => x.AssignAsync(
+ accessContext,
+ target,
+ "Administrators",
+ ctx.RequestAborted))
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.AssignRoleAsync(
+ target,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.UserRoles.Verify(x => x.AssignAsync(
+ accessContext,
+ target,
+ "Administrators",
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Remove
+ // =========================================================
+
+ [Fact]
+ public async Task RemoveRoleAsync_WhenAuthenticated_UsesTargetUserAndRoleName()
+ {
+ var f = new Fixture();
+ var target = UserKey.New();
+
+ var ctx = f.Json(new RemoveRoleRequest
+ {
+ UserKey = target,
+ RoleName = "Administrators"
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.RemoveAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.RemoveAdmin,
+ "authorization.roles",
+ target.Value,
+ accessContext);
+
+ f.UserRoles
+ .Setup(x => x.RemoveAsync(
+ accessContext,
+ target,
+ "Administrators",
+ ctx.RequestAborted))
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.RemoveRoleAsync(
+ target,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.UserRoles.Verify(x => x.RemoveAsync(
+ accessContext,
+ target,
+ "Administrators",
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Create Role
+ // =========================================================
+
+ [Fact]
+ public async Task CreateRoleAsync_WhenAuthenticated_ForwardsRequest()
+ {
+ var f = new Fixture();
+
+ var permissions = new[]
+ {
+ Permission.From("users.read")
+ };
+
+ var ctx = f.Json(new CreateRoleRequest
+ {
+ Name = "Administrators",
+ Permissions = permissions
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.CreateAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.CreateAdmin,
+ "authorization.roles",
+ null,
+ accessContext);
+
+ var role = Role.Create(
+ RoleId.New(),
+ TenantKey.Single,
+ "Administrators",
+ permissions,
+ DateTimeOffset.UtcNow);
+
+ f.Roles
+ .Setup(x => x.CreateAsync(
+ accessContext,
+ "Administrators",
+ It.Is>(p =>
+ p.SequenceEqual(permissions)),
+ ctx.RequestAborted))
+ .ReturnsAsync(role);
+
+ var result = await f.Sut.CreateRoleAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.Roles.Verify(x => x.CreateAsync(
+ accessContext,
+ "Administrators",
+ It.IsAny>(),
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Rename Role
+ // =========================================================
+
+ [Fact]
+ public async Task RenameRoleAsync_WhenAuthenticated_UsesRouteRoleId()
+ {
+ var f = new Fixture();
+ var roleId = RoleId.New();
+
+ var ctx = f.Json(new RenameRoleRequest
+ {
+ Id = roleId,
+ Name = "Operators"
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.RenameAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.RenameAdmin,
+ "authorization.roles",
+ roleId.ToString(),
+ accessContext);
+
+ f.Roles
+ .Setup(x => x.RenameAsync(
+ accessContext,
+ roleId,
+ "Operators",
+ ctx.RequestAborted))
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.RenameRoleAsync(
+ roleId,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+ }
+
+ // =========================================================
+ // Delete Role
+ // =========================================================
+
+ [Fact]
+ public async Task DeleteRoleAsync_WhenAuthenticated_ForwardsDeleteMode()
+ {
+ var f = new Fixture();
+ var roleId = RoleId.New();
+
+ var ctx = f.Json(new DeleteRoleRequest
+ {
+ Id = roleId,
+ Mode = DeleteMode.Hard
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.DeleteAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.DeleteAdmin,
+ "authorization.roles",
+ roleId.ToString(),
+ accessContext);
+
+ var expected = new DeleteRoleResult
+ {
+ RoleId = roleId,
+ Mode = DeleteMode.Hard,
+ RemovedAssignments = 3,
+ DeletedAt = DateTimeOffset.UtcNow
+ };
+
+ f.Roles
+ .Setup(x => x.DeleteAsync(
+ accessContext,
+ roleId,
+ DeleteMode.Hard,
+ ctx.RequestAborted))
+ .ReturnsAsync(expected);
+
+ var result = await f.Sut.DeleteRoleAsync(
+ roleId,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+
+ f.Roles.Verify(x => x.DeleteAsync(
+ accessContext,
+ roleId,
+ DeleteMode.Hard,
+ ctx.RequestAborted),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Set Permissions
+ // =========================================================
+
+ [Fact]
+ public async Task SetRolePermissionsAsync_WhenAuthenticated_ForwardsPermissions()
+ {
+ var f = new Fixture();
+ var roleId = RoleId.New();
+
+ var permissions = new[]
+ {
+ Permission.From("users.read")
+ };
+
+ var ctx = f.Json(new SetRolePermissionsRequest
+ {
+ RoleId = roleId,
+ Permissions = permissions
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.SetPermissionsAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.SetPermissionsAdmin,
+ "authorization.roles",
+ roleId.ToString(),
+ accessContext);
+
+ f.Roles
+ .Setup(x => x.SetPermissionsAsync(
+ accessContext,
+ roleId,
+ It.Is>(p =>
+ p.SequenceEqual(permissions)),
+ ctx.RequestAborted))
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.SetRolePermissionsAsync(
+ roleId,
+ ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+ }
+
+ // =========================================================
+ // Query Roles
+ // =========================================================
+
+ [Fact]
+ public async Task QueryRolesAsync_WhenAuthenticated_ForwardsQuery()
+ {
+ var f = new Fixture();
+
+ var ctx = f.Json(new RoleQuery
+ {
+ Search = "admin",
+ IncludeDeleted = true,
+ PageNumber = 2,
+ PageSize = 10,
+ SortBy = nameof(Role.Name),
+ Descending = true
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.QueryAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.QueryAdmin,
+ "authorization.roles",
+ null,
+ accessContext);
+
+ var expected = new PagedResult(
+ [],
+ 0,
+ 2,
+ 10,
+ nameof(Role.Name),
+ true);
+
+ f.Roles
+ .Setup(x => x.QueryAsync(
+ accessContext,
+ It.Is(q =>
+ q.Search == "admin" &&
+ q.IncludeDeleted &&
+ q.PageNumber == 2 &&
+ q.PageSize == 10 &&
+ q.SortBy == nameof(Role.Name) &&
+ q.Descending),
+ ctx.RequestAborted))
+ .ReturnsAsync(expected);
+
+ var result = await f.Sut.QueryRolesAsync(ctx);
+
+ AssertStatusCode(
+ result,
+ StatusCodes.Status200OK);
+ }
+
+ [Fact]
+ public async Task RenameRoleAsync_WhenBodyContainsDifferentId_UsesRouteRoleId()
+ {
+ var f = new Fixture();
+
+ var routeRoleId = RoleId.New();
+ var bodyRoleId = RoleId.New();
+
+ var ctx = f.Json(new RenameRoleRequest
+ {
+ Id = bodyRoleId,
+ Name = "Operators"
+ });
+
+ var accessContext = f.AccessContext(
+ UAuthActions.Authorization.Roles.RenameAdmin);
+
+ f.SetupAccessContext(
+ UAuthActions.Authorization.Roles.RenameAdmin,
+ "authorization.roles",
+ routeRoleId.ToString(),
+ accessContext);
+
+ f.Roles
+ .Setup(x => x.RenameAsync(
+ accessContext,
+ routeRoleId,
+ "Operators",
+ ctx.RequestAborted))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.RenameRoleAsync(routeRoleId, ctx);
+
+ f.Roles.Verify(x => x.RenameAsync(
+ accessContext,
+ routeRoleId,
+ "Operators",
+ ctx.RequestAborted),
+ Times.Once);
+
+ f.Roles.Verify(x => x.RenameAsync(
+ It.IsAny(),
+ bodyRoleId,
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ // =========================================================
+ // Fixture
+ // =========================================================
+
+ private sealed class Fixture
+ {
+ public UserKey UserKey { get; } = UserKey.New();
+
+ public Mock AuthFlow { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock Authorization { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock UserRoles { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock Roles { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock AccessContextFactory { get; }
+ = new(MockBehavior.Strict);
+
+ public AuthorizationEndpointHandler Sut { get; }
+
+ public Fixture(bool isAuthenticated = true)
+ {
+ var flow = AuthFlowTestFactory.New(
+ isAuthenticated: isAuthenticated,
+ userKey: isAuthenticated ? UserKey : null);
+
+ AuthFlow
+ .SetupGet(x => x.Current)
+ .Returns(flow);
+
+ Sut = new AuthorizationEndpointHandler(
+ AuthFlow.Object,
+ Authorization.Object,
+ UserRoles.Object,
+ Roles.Object,
+ AccessContextFactory.Object);
+ }
+
+ public DefaultHttpContext Http()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.Response.Body = new MemoryStream();
+ return ctx;
+ }
+
+ public DefaultHttpContext Json(T value)
+ {
+ var ctx = Http();
+
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
+
+ ctx.Request.ContentType = "application/json";
+ ctx.Request.Body = new MemoryStream(bytes);
+ ctx.Request.ContentLength = bytes.Length;
+
+ return ctx;
+ }
+
+ public AccessContext AccessContext(string action)
+ => TestAccessContext.WithAction(action);
+
+ public void SetupAccessContext(
+ string action,
+ string resource,
+ string? resourceId,
+ AccessContext result)
+ {
+ AccessContextFactory
+ .Setup(x => x.CreateAsync(
+ It.IsAny(),
+ action,
+ resource,
+ resourceId,
+ It.IsAny?>(),
+ It.IsAny()))
+ .ReturnsAsync(result);
+ }
+ }
+
+ private static PagedResult EmptyRoles()
+ => new(
+ [],
+ 0,
+ 1,
+ 250,
+ null,
+ false);
+
+ private static void AssertStatusCode(
+ IResult result,
+ int expectedStatusCode)
+ {
+ result.Should().BeAssignableTo();
+
+ var statusResult = (IStatusCodeHttpResult)result;
+
+ statusResult.StatusCode.Should().Be(expectedStatusCode);
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs
new file mode 100644
index 00000000..6e0fe9c7
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs
@@ -0,0 +1,122 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Authorization.Reference;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+using Moq;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit;
+
+public sealed class AuthorizationServiceTests
+{
+ [Fact]
+ public async Task AuthorizeAsync_WhenAccessOrchestratorAllows_ReturnsAllowed()
+ {
+ var f = new Fixture();
+ var context = TestAccessContext.WithAction("orders.read");
+
+ f.AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ context,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ async (_, command, ct) => await command.ExecuteAsync(ct));
+
+ var result = await f.Sut.AuthorizeAsync(context);
+
+ result.IsAllowed.Should().BeTrue();
+ result.RequiresReauthentication.Should().BeFalse();
+ result.DenyReason.Should().BeNull();
+
+ f.AccessOrchestrator.Verify(x => x.ExecuteAsync(
+ context,
+ It.IsAny>(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task AuthorizeAsync_WhenAccessOrchestratorDenies_ReturnsDenied()
+ {
+ var f = new Fixture();
+ var context = TestAccessContext.WithAction("orders.delete");
+
+ var exception = new UAuthAuthorizationException(
+ "access_denied");
+
+ f.AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ context,
+ It.IsAny>(),
+ It.IsAny()))
+ .ThrowsAsync(exception);
+
+ var result = await f.Sut.AuthorizeAsync(context);
+
+ result.IsAllowed.Should().BeFalse();
+ result.RequiresReauthentication.Should().BeFalse();
+
+ // This deliberately tests CURRENT service behavior.
+ result.DenyReason.Should().Be(exception.Message);
+ }
+
+ [Fact]
+ public async Task AuthorizeAsync_WhenUnexpectedExceptionOccurs_DoesNotSwallowException()
+ {
+ var f = new Fixture();
+ var context = TestAccessContext.WithAction("orders.read");
+
+ var expected = new InvalidOperationException(
+ "store unavailable");
+
+ f.AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ context,
+ It.IsAny>(),
+ It.IsAny()))
+ .ThrowsAsync(expected);
+
+ var act = () => f.Sut.AuthorizeAsync(context);
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Should().BeSameAs(expected);
+ }
+
+ [Fact]
+ public async Task AuthorizeAsync_WhenAlreadyCancelled_ThrowsBeforeAccessExecution()
+ {
+ var f = new Fixture();
+ var context = TestAccessContext.WithAction("orders.read");
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var act = () => f.Sut.AuthorizeAsync(
+ context,
+ cts.Token);
+
+ await act.Should()
+ .ThrowAsync();
+
+ f.AccessOrchestrator.VerifyNoOtherCalls();
+ }
+
+ private sealed class Fixture
+ {
+ public Mock AccessOrchestrator { get; }
+ = new(MockBehavior.Strict);
+
+ public AuthorizationService Sut { get; }
+
+ public Fixture()
+ {
+ Sut = new AuthorizationService(
+ AccessOrchestrator.Object);
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs
new file mode 100644
index 00000000..6c01265b
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs
@@ -0,0 +1,529 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Authorization.Reference;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+using Moq;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit;
+
+public sealed class RoleServiceTests
+{
+ private static readonly DateTimeOffset Now =
+ new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero);
+
+ // =========================================================
+ // Create
+ // =========================================================
+
+ [Fact]
+ public async Task CreateAsync_WhenValid_CreatesAndPersistsRole()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.create");
+
+ var permissions = new[]
+ {
+ Permission.From("users.read"),
+ Permission.From("users.write")
+ };
+
+ Role? captured = null;
+
+ f.RoleStore
+ .Setup(x => x.AddAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((role, _) => captured = role)
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.CreateAsync(
+ context,
+ " Administrators ",
+ permissions);
+
+ result.Should().BeSameAs(captured);
+
+ captured.Should().NotBeNull();
+ captured!.Tenant.Should().Be(context.ResourceTenant);
+ captured.Name.Should().Be("Administrators");
+ captured.NormalizedName.Should().Be("ADMINISTRATORS");
+ captured.CreatedAt.Should().Be(Now);
+ captured.Permissions.Should().BeEquivalentTo(permissions);
+
+ f.RoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Rename
+ // =========================================================
+
+ [Fact]
+ public async Task RenameAsync_WhenRoleDoesNotExist_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.rename");
+ var roleId = RoleId.New();
+
+ f.RoleStore
+ .Setup(x => x.GetAsync(
+ new RoleKey(context.ResourceTenant, roleId),
+ It.IsAny()))
+ .ReturnsAsync((Role?)null);
+
+ var act = () => f.Sut.RenameAsync(
+ context,
+ roleId,
+ "New Name");
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+
+ f.RoleStore.Verify(
+ x => x.SaveAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task RenameAsync_WhenRoleIsDeleted_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.rename");
+ var role = f.Role("Old Name", version: 4);
+
+ role.MarkDeleted(Now.AddMinutes(-1));
+
+ f.SetupGetRole(context, role);
+
+ var act = () => f.Sut.RenameAsync(
+ context,
+ role.Id,
+ "New Name");
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+
+ f.RoleStore.Verify(
+ x => x.SaveAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task RenameAsync_WhenValid_RenamesAndSavesWithOriginalVersion()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.rename");
+ var role = f.Role("Old Name", version: 7);
+
+ f.SetupGetRole(context, role);
+
+ f.RoleStore
+ .Setup(x => x.SaveAsync(
+ role,
+ 7,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.RenameAsync(
+ context,
+ role.Id,
+ " New Name ");
+
+ role.Name.Should().Be("New Name");
+ role.NormalizedName.Should().Be("NEW NAME");
+ role.UpdatedAt.Should().Be(Now);
+
+ f.RoleStore.Verify(
+ x => x.SaveAsync(
+ role,
+ 7,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Delete
+ // =========================================================
+
+ [Fact]
+ public async Task DeleteAsync_WhenRoleDoesNotExist_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.delete");
+ var roleId = RoleId.New();
+
+ f.RoleStore
+ .Setup(x => x.GetAsync(
+ new RoleKey(context.ResourceTenant, roleId),
+ It.IsAny()))
+ .ReturnsAsync((Role?)null);
+
+ var act = () => f.Sut.DeleteAsync(
+ context,
+ roleId,
+ DeleteMode.Soft);
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+
+ f.UserRoleStore.VerifyNoOtherCalls();
+ }
+
+ [Theory]
+ [InlineData(DeleteMode.Soft)]
+ [InlineData(DeleteMode.Hard)]
+ public async Task DeleteAsync_WhenValid_RemovesAssignmentsAndUsesRequestedMode(
+ DeleteMode mode)
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.delete");
+ var role = f.Role("Admin", version: 9);
+
+ f.SetupGetRole(context, role);
+
+ f.UserRoleStore
+ .Setup(x => x.CountAssignmentsAsync(
+ role.Id,
+ It.IsAny()))
+ .ReturnsAsync(13);
+
+ f.UserRoleStore
+ .Setup(x => x.RemoveAssignmentsByRoleAsync(
+ role.Id,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ f.RoleStore
+ .Setup(x => x.DeleteAsync(
+ new RoleKey(context.ResourceTenant, role.Id),
+ 9,
+ mode,
+ Now,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var result = await f.Sut.DeleteAsync(
+ context,
+ role.Id,
+ mode);
+
+ result.RoleId.Should().Be(role.Id);
+ result.RemovedAssignments.Should().Be(13);
+ result.Mode.Should().Be(mode);
+ result.DeletedAt.Should().Be(Now);
+
+ f.UserRoleStore.Verify(
+ x => x.RemoveAssignmentsByRoleAsync(
+ role.Id,
+ It.IsAny()),
+ Times.Once);
+
+ f.RoleStore.Verify(
+ x => x.DeleteAsync(
+ new RoleKey(context.ResourceTenant, role.Id),
+ 9,
+ mode,
+ Now,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Set Permissions
+ // =========================================================
+
+ [Fact]
+ public async Task SetPermissionsAsync_WhenRoleDoesNotExist_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.permissions.set");
+ var roleId = RoleId.New();
+
+ f.RoleStore
+ .Setup(x => x.GetAsync(
+ new RoleKey(context.ResourceTenant, roleId),
+ It.IsAny()))
+ .ReturnsAsync((Role?)null);
+
+ var act = () => f.Sut.SetPermissionsAsync(
+ context,
+ roleId,
+ [Permission.From("users.read")]);
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+ }
+
+ [Fact]
+ public async Task SetPermissionsAsync_WhenRoleIsDeleted_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.permissions.set");
+ var role = f.Role("Admin", version: 3);
+
+ role.MarkDeleted(Now.AddMinutes(-1));
+
+ f.SetupGetRole(context, role);
+
+ var act = () => f.Sut.SetPermissionsAsync(
+ context,
+ role.Id,
+ [Permission.From("users.read")]);
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+ }
+
+ [Fact]
+ public async Task SetPermissionsAsync_WhenValid_UpdatesAndSavesWithOriginalVersion()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.permissions.set");
+
+ var role = f.Role("Admin", version: 12);
+
+ f.SetupGetRole(context, role);
+
+ f.RoleStore
+ .Setup(x => x.SaveAsync(
+ role,
+ 12,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var permissions = new[]
+ {
+ Permission.From("users.read")
+ };
+
+ await f.Sut.SetPermissionsAsync(
+ context,
+ role.Id,
+ permissions);
+
+ role.Permissions.Should().ContainSingle().Which.Should().Be(Permission.From("users.read"));
+
+ role.UpdatedAt.Should().Be(Now);
+
+ f.RoleStore.Verify(
+ x => x.SaveAsync(
+ role,
+ 12,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Query
+ // =========================================================
+
+ [Fact]
+ public async Task QueryAsync_ForwardsQueryAndReturnsStoreResult()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+
+ var query = new RoleQuery
+ {
+ Search = "admin",
+ IncludeDeleted = true,
+ PageNumber = 2,
+ PageSize = 25,
+ SortBy = "name",
+ Descending = true
+ };
+
+ var roles = new[]
+ {
+ f.Role("Admin"),
+ f.Role("Super Admin")
+ };
+
+ var expected = new PagedResult(
+ roles,
+ totalCount: 42,
+ pageNumber: 2,
+ pageSize: 25,
+ sortBy: "name",
+ descending: true);
+
+ f.RoleStore
+ .Setup(x => x.QueryAsync(
+ query,
+ It.IsAny()))
+ .ReturnsAsync(expected);
+
+ var result = await f.Sut.QueryAsync(context, query);
+
+ result.Should().BeSameAs(expected);
+
+ f.RoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Cancellation
+ // =========================================================
+
+ [Fact]
+ public async Task CreateAsync_WhenAlreadyCancelled_ThrowsBeforeAccessExecution()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.create");
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var act = () => f.Sut.CreateAsync(
+ context,
+ "Admin",
+ null,
+ cts.Token);
+
+ await act.Should()
+ .ThrowAsync();
+
+ f.AccessOrchestrator.VerifyNoOtherCalls();
+ f.RoleFactory.VerifyNoOtherCalls();
+ }
+
+ // =========================================================
+ // Fixture
+ // =========================================================
+
+ private sealed class Fixture
+ {
+ public Mock AccessOrchestrator { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock RoleFactory { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock RoleStore { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock UserRoleFactory { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock UserRoleStore { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock Clock { get; }
+ = new(MockBehavior.Strict);
+
+ public RoleService Sut { get; }
+
+ public Fixture()
+ {
+ Clock
+ .SetupGet(x => x.UtcNow)
+ .Returns(Now);
+
+ // AccessOrchestrator is deliberately transparent here.
+ // RoleService behavior is the subject under test.
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns, CancellationToken>(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny>>(),
+ It.IsAny()))
+ .Returns>, CancellationToken>(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ RoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(RoleStore.Object);
+
+ UserRoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(UserRoleStore.Object);
+
+ Sut = new RoleService(
+ AccessOrchestrator.Object,
+ RoleFactory.Object,
+ UserRoleFactory.Object,
+ Clock.Object);
+ }
+
+ public AccessContext Context(string action)
+ => TestAccessContext.WithAction(action);
+
+ public Role Role(
+ string name,
+ long version = 0,
+ IEnumerable? permissions = null)
+ {
+ var role = global::CodeBeam.UltimateAuth.Authorization.Role.Create(
+ RoleId.New(),
+ TenantKey.Single,
+ name,
+ permissions,
+ Now.AddHours(-1));
+
+ role.Version = version;
+ return role;
+ }
+
+ public void SetupGetRole(
+ AccessContext context,
+ Role role)
+ {
+ RoleStore
+ .Setup(x => x.GetAsync(
+ new RoleKey(context.ResourceTenant, role.Id),
+ It.IsAny()))
+ .ReturnsAsync(role);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs
new file mode 100644
index 00000000..c16a3583
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs
@@ -0,0 +1,65 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Tests.Contracts.Authorization;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts;
+
+public sealed class EfCoreRoleStoreContractTests : RoleStoreContractTests
+{
+ protected override async Task CreateDatabaseAsync()
+ {
+ var database = new EfRoleStoreTestDatabase();
+ await database.InitializeAsync();
+
+ return database;
+ }
+
+ private sealed class EfRoleStoreTestDatabase : IRoleStoreTestDatabase
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _options;
+ private readonly List _contexts = [];
+
+ public EfRoleStoreTestDatabase()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+
+ _options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _connection.OpenAsync();
+
+ await using var db = new UAuthAuthorizationDbContext(_options);
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public IRoleStore CreateStore(TenantKey tenant)
+ {
+ var db = new UAuthAuthorizationDbContext(_options);
+
+ _contexts.Add(db);
+
+ return new EfCoreRoleStore(
+ db,
+ new TenantExecutionContext(tenant));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var context in _contexts)
+ {
+ await context.DisposeAsync();
+ }
+
+ await _connection.DisposeAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs
new file mode 100644
index 00000000..c4ae4559
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs
@@ -0,0 +1,74 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts;
+
+public sealed class EfCoreUserRoleStoreContractTests : UserRoleStoreContractTests
+{
+ protected override async Task
+ CreateDatabaseAsync()
+ {
+ var database = new EfUserRoleStoreTestDatabase();
+
+ await database.InitializeAsync();
+
+ return database;
+ }
+
+ private sealed class EfUserRoleStoreTestDatabase : IUserRoleStoreTestDatabase
+ {
+ private readonly SqliteConnection _connection;
+
+ private readonly DbContextOptions
+ _options;
+
+ private readonly List
+ _contexts = [];
+
+ public EfUserRoleStoreTestDatabase()
+ {
+ _connection =
+ new SqliteConnection("Data Source=:memory:");
+
+ _options =
+ new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _connection.OpenAsync();
+
+ await using var db =
+ new UAuthAuthorizationDbContext(_options);
+
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public IUserRoleStore CreateStore(TenantKey tenant)
+ {
+ var db =
+ new UAuthAuthorizationDbContext(_options);
+
+ _contexts.Add(db);
+
+ return new EfCoreUserRoleStore(
+ db,
+ new TenantExecutionContext(tenant));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var context in _contexts)
+ {
+ await context.DisposeAsync();
+ }
+
+ await _connection.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs
new file mode 100644
index 00000000..34cb42b3
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs
@@ -0,0 +1,9 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization;
+
+public interface IRoleStoreTestDatabase : IAsyncDisposable
+{
+ IRoleStore CreateStore(TenantKey tenant);
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs
new file mode 100644
index 00000000..a4bcb503
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs
@@ -0,0 +1,9 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts;
+
+public interface IUserRoleStoreTestDatabase : IAsyncDisposable
+{
+ IUserRoleStore CreateStore(TenantKey tenant);
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs
new file mode 100644
index 00000000..e5029507
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs
@@ -0,0 +1,31 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.InMemory;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+
+namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization;
+
+public sealed class InMemoryRoleStoreContractTests : RoleStoreContractTests
+{
+ protected override Task CreateDatabaseAsync()
+ {
+ return Task.FromResult(
+ new InMemoryRoleStoreTestDatabase());
+ }
+
+ private sealed class InMemoryRoleStoreTestDatabase
+ : IRoleStoreTestDatabase
+ {
+ public IRoleStore CreateStore(TenantKey tenant)
+ {
+ var executionContext =
+ new TenantExecutionContext(tenant);
+
+ return new InMemoryRoleStore(
+ executionContext);
+ }
+
+ public ValueTask DisposeAsync()
+ => ValueTask.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs
new file mode 100644
index 00000000..08ed7007
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs
@@ -0,0 +1,37 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.InMemory;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts;
+
+public sealed class InMemoryUserRoleStoreContractTests : UserRoleStoreContractTests
+{
+ protected override Task
+ CreateDatabaseAsync()
+ {
+ return Task.FromResult(
+ new InMemoryUserRoleStoreTestDatabase());
+ }
+
+ private sealed class InMemoryUserRoleStoreTestDatabase : IUserRoleStoreTestDatabase
+ {
+ private readonly Dictionary
+ _stores = [];
+
+ public IUserRoleStore CreateStore(TenantKey tenant)
+ {
+ if (_stores.TryGetValue(tenant, out var store))
+ return store;
+
+ store = new InMemoryUserRoleStore(
+ new TenantExecutionContext(tenant));
+
+ _stores.Add(tenant, store);
+
+ return store;
+ }
+
+ public ValueTask DisposeAsync()
+ => ValueTask.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs
new file mode 100644
index 00000000..04f647f1
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs
@@ -0,0 +1,564 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+
+namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization;
+
+public abstract class RoleStoreContractTests
+{
+ protected abstract Task CreateDatabaseAsync();
+
+ protected virtual TenantKey Tenant =>
+ TenantKey.Single;
+
+ // ---------------------------------------------------------
+ // Add / Get
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task AddAsync_WhenValid_PersistsRole()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var result = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(role.Id);
+ result.Tenant.Should().Be(Tenant);
+ result.Name.Should().Be(role.Name);
+ result.NormalizedName.Should().Be(role.NormalizedName);
+ result.Permissions.Should().BeEquivalentTo(role.Permissions);
+ }
+
+ [Fact]
+ public async Task AddAsync_WhenActiveNormalizedNameAlreadyExists_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var first = CreateRole("Administrators");
+ var duplicate = CreateRole(" administrators ");
+
+ await store.AddAsync(first);
+
+ var act = () => store.AddAsync(duplicate);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task AddAsync_WhenDeletedRoleHasSameNormalizedName_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var first = CreateRole("Administrators");
+
+ await store.AddAsync(first);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, first.Id),
+ first.Version,
+ DeleteMode.Soft,
+ Now);
+
+ var replacement = CreateRole("Administrators");
+
+ var act = () => store.AddAsync(replacement);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task AddAsync_WhenPreviousRoleWasHardDeleted_AllowsNameReuse()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var first = CreateRole("Administrators");
+
+ await store.AddAsync(first);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, first.Id),
+ first.Version,
+ DeleteMode.Hard,
+ Now);
+
+ var replacement = CreateRole("Administrators");
+
+ var act = () => store.AddAsync(replacement);
+
+ await act.Should().NotThrowAsync();
+
+ var result = await store.GetByNameAsync(
+ replacement.NormalizedName);
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(replacement.Id);
+ }
+
+ // ---------------------------------------------------------
+ // Name lookup
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task GetByNameAsync_WhenRoleExists_ReturnsRole()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var result = await store.GetByNameAsync(
+ role.NormalizedName);
+
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(role.Id);
+ result.Name.Should().Be(role.Name);
+ }
+
+ [Fact]
+ public async Task GetByNameAsync_WhenRoleIsDeleted_ReturnsNull()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, role.Id),
+ role.Version,
+ DeleteMode.Soft,
+ Now);
+
+ var result = await store.GetByNameAsync(
+ role.NormalizedName);
+
+ result.Should().BeNull();
+ }
+
+ // ---------------------------------------------------------
+ // GetByIds
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task GetByIdsAsync_ReturnsOnlyRequestedRoles()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var admin = CreateRole("Administrators");
+ var operatorRole = CreateRole("Operators");
+ var unrelated = CreateRole("Auditors");
+
+ await store.AddAsync(admin);
+ await store.AddAsync(operatorRole);
+ await store.AddAsync(unrelated);
+
+ var result = await store.GetByIdsAsync(
+ [admin.Id, operatorRole.Id]);
+
+ result.Select(x => x.Id)
+ .Should()
+ .BeEquivalentTo([admin.Id, operatorRole.Id]);
+
+ result.Should()
+ .NotContain(x => x.Id == unrelated.Id);
+ }
+
+ [Fact]
+ public async Task GetByIdsAsync_DoesNotReturnDeletedRoles()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var active = CreateRole("Active");
+ var deleted = CreateRole("Deleted");
+
+ await store.AddAsync(active);
+ await store.AddAsync(deleted);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, deleted.Id),
+ deleted.Version,
+ DeleteMode.Soft,
+ Now);
+
+ var result = await store.GetByIdsAsync(
+ [active.Id, deleted.Id]);
+
+ result.Should().ContainSingle();
+ result.Single().Id.Should().Be(active.Id);
+ }
+
+ // ---------------------------------------------------------
+ // Save / concurrency
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task SaveAsync_WhenRoleIsChanged_PersistsChanges()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var persisted = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ persisted.Should().NotBeNull();
+
+ var expectedVersion = persisted!.Version;
+
+ persisted.Rename("Operators", Now.AddMinutes(1));
+
+ await store.SaveAsync(
+ persisted,
+ expectedVersion);
+
+ var result = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("Operators");
+ result.Version.Should().BeGreaterThan(expectedVersion);
+ }
+
+ [Fact]
+ public async Task SaveAsync_WhenExpectedVersionIsStale_ThrowsConcurrency()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var persisted = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ persisted.Should().NotBeNull();
+
+ persisted!.Rename(
+ "Operators",
+ Now.AddMinutes(1));
+
+ var staleVersion = persisted.Version + 100;
+
+ var act = () => store.SaveAsync(
+ persisted,
+ staleVersion);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task SaveAsync_WhenRenamedToExistingActiveRole_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var admin = CreateRole("Administrators");
+ var operators = CreateRole("Operators");
+
+ await store.AddAsync(admin);
+ await store.AddAsync(operators);
+
+ var persisted = await store.GetAsync(
+ new RoleKey(Tenant, operators.Id));
+
+ persisted.Should().NotBeNull();
+
+ var expectedVersion = persisted!.Version;
+
+ persisted.Rename(
+ "Administrators",
+ Now.AddMinutes(1));
+
+ var act = () => store.SaveAsync(
+ persisted,
+ expectedVersion);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ // ---------------------------------------------------------
+ // Delete
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task DeleteAsync_WhenSoftDeleted_KeepsRoleButMarksDeleted()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var deletedAt = Now.AddMinutes(1);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, role.Id),
+ role.Version,
+ DeleteMode.Soft,
+ deletedAt);
+
+ var result = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ result.Should().NotBeNull();
+ result!.IsDeleted.Should().BeTrue();
+ result.DeletedAt.Should().Be(deletedAt);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_WhenHardDeleted_RemovesRole()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, role.Id),
+ role.Version,
+ DeleteMode.Hard,
+ Now);
+
+ var result = await store.GetAsync(
+ new RoleKey(Tenant, role.Id));
+
+ result.Should().BeNull();
+
+ var exists = await store.ExistsAsync(
+ new RoleKey(Tenant, role.Id));
+
+ exists.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task DeleteAsync_WhenExpectedVersionIsStale_ThrowsConcurrency()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var role = CreateRole("Administrators");
+
+ await store.AddAsync(role);
+
+ var act = () => store.DeleteAsync(
+ new RoleKey(Tenant, role.Id),
+ role.Version + 100,
+ DeleteMode.Soft,
+ Now);
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ // ---------------------------------------------------------
+ // Query
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task QueryAsync_WhenIncludeDeletedIsFalse_ExcludesDeletedRoles()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var active = CreateRole("Active");
+ var deleted = CreateRole("Deleted");
+
+ await store.AddAsync(active);
+ await store.AddAsync(deleted);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, deleted.Id),
+ deleted.Version,
+ DeleteMode.Soft,
+ Now);
+
+ var result = await store.QueryAsync(
+ new RoleQuery
+ {
+ IncludeDeleted = false
+ });
+
+ result.Items.Should()
+ .Contain(x => x.Id == active.Id);
+
+ result.Items.Should()
+ .NotContain(x => x.Id == deleted.Id);
+ }
+
+ [Fact]
+ public async Task QueryAsync_WhenIncludeDeletedIsTrue_IncludesDeletedRoles()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var active = CreateRole("Active");
+ var deleted = CreateRole("Deleted");
+
+ await store.AddAsync(active);
+ await store.AddAsync(deleted);
+
+ await store.DeleteAsync(
+ new RoleKey(Tenant, deleted.Id),
+ deleted.Version,
+ DeleteMode.Soft,
+ Now);
+
+ var result = await store.QueryAsync(
+ new RoleQuery
+ {
+ IncludeDeleted = true
+ });
+
+ result.Items.Select(x => x.Id)
+ .Should()
+ .Contain([active.Id, deleted.Id]);
+ }
+
+ [Fact]
+ public async Task QueryAsync_WhenSearchSpecified_SearchesNormalizedName()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var admin = CreateRole("Global Administrators");
+ var operators = CreateRole("Operators");
+
+ await store.AddAsync(admin);
+ await store.AddAsync(operators);
+
+ var result = await store.QueryAsync(
+ new RoleQuery
+ {
+ Search = " admin "
+ });
+
+ result.Items.Should().ContainSingle();
+ result.Items.Single().Id.Should().Be(admin.Id);
+ }
+
+ [Fact]
+ public async Task QueryAsync_SortsBeforeApplyingPagination()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ foreach (var name in new[]
+ {
+ "Charlie",
+ "Alpha",
+ "Echo",
+ "Bravo",
+ "Delta"
+ })
+ {
+ await store.AddAsync(CreateRole(name));
+ }
+
+ var result = await store.QueryAsync(
+ new RoleQuery
+ {
+ PageNumber = 2,
+ PageSize = 2,
+ SortBy = nameof(Role.Name),
+ Descending = false
+ });
+
+ result.TotalCount.Should().Be(5);
+ result.PageNumber.Should().Be(2);
+ result.PageSize.Should().Be(2);
+
+ result.Items
+ .Select(x => x.Name)
+ .Should()
+ .ContainInOrder("Charlie", "Delta");
+ }
+
+ // ---------------------------------------------------------
+ // Tenant isolation
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task Store_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var roleA = CreateRole(
+ "Administrators",
+ tenantA);
+
+ await storeA.AddAsync(roleA);
+
+ var fromA = await storeA.GetAsync(
+ new RoleKey(tenantA, roleA.Id));
+
+ var fromB = await storeB.GetAsync(
+ new RoleKey(tenantB, roleA.Id));
+
+ fromA.Should().NotBeNull();
+ fromB.Should().BeNull();
+
+ var queryB = await storeB.QueryAsync(
+ new RoleQuery
+ {
+ IncludeDeleted = true
+ });
+
+ queryB.Items.Should()
+ .NotContain(x => x.Id == roleA.Id);
+ }
+
+ // ---------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------
+
+ protected static readonly DateTimeOffset Now =
+ new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ protected Role CreateRole(
+ string name,
+ TenantKey? tenant = null)
+ {
+ return Role.Create(
+ RoleId.New(),
+ tenant ?? Tenant,
+ name,
+ [],
+ Now);
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs
new file mode 100644
index 00000000..425cb690
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs
@@ -0,0 +1,418 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts;
+
+public abstract class UserRoleStoreContractTests
+{
+ protected abstract Task CreateDatabaseAsync();
+
+ protected virtual TenantKey Tenant => TenantKeys.Single;
+
+ protected static readonly DateTimeOffset Now =
+ new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+
+ // ---------------------------------------------------------
+ // Assign
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task AssignAsync_WhenValid_PersistsAssignment()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+ var roleId = RoleId.New();
+ var assignedAt = Now.AddMinutes(-10);
+
+ await store.AssignAsync(user, roleId, assignedAt);
+
+ var result = await store.GetAssignmentsAsync(user);
+
+ result.Should().ContainSingle();
+
+ var assignment = result.Single();
+
+ assignment.Tenant.Should().Be(Tenant);
+ assignment.UserKey.Should().Be(user);
+ assignment.RoleId.Should().Be(roleId);
+ assignment.AssignedAt.Should().Be(assignedAt);
+ }
+
+ [Fact]
+ public async Task AssignAsync_WhenSameRoleAlreadyAssigned_ThrowsConflict()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+ var roleId = RoleId.New();
+
+ await store.AssignAsync(user, roleId, Now);
+
+ var act = () => store.AssignAsync(
+ user,
+ roleId,
+ Now.AddMinutes(1));
+
+ await act.Should()
+ .ThrowAsync();
+ }
+
+ [Fact]
+ public async Task AssignAsync_SameUserCanHaveMultipleRoles()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var roleA = RoleId.New();
+ var roleB = RoleId.New();
+
+ await store.AssignAsync(user, roleA, Now);
+ await store.AssignAsync(user, roleB, Now.AddMinutes(1));
+
+ var result = await store.GetAssignmentsAsync(user);
+
+ result.Should().HaveCount(2);
+
+ result.Select(x => x.RoleId)
+ .Should()
+ .BeEquivalentTo([roleA, roleB]);
+ }
+
+ [Fact]
+ public async Task AssignAsync_SameRoleCanBeAssignedToMultipleUsers()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var userA = UserKey.New();
+ var userB = UserKey.New();
+ var roleId = RoleId.New();
+
+ await store.AssignAsync(userA, roleId, Now);
+ await store.AssignAsync(userB, roleId, Now.AddMinutes(1));
+
+ var assignmentsA = await store.GetAssignmentsAsync(userA);
+ var assignmentsB = await store.GetAssignmentsAsync(userB);
+
+ assignmentsA.Should().ContainSingle();
+ assignmentsB.Should().ContainSingle();
+
+ assignmentsA.Single().RoleId.Should().Be(roleId);
+ assignmentsB.Single().RoleId.Should().Be(roleId);
+ }
+
+ // ---------------------------------------------------------
+ // GetAssignments
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task GetAssignmentsAsync_ReturnsOnlyRequestedUserAssignments()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var userA = UserKey.New();
+ var userB = UserKey.New();
+
+ var roleA = RoleId.New();
+ var roleB = RoleId.New();
+
+ await store.AssignAsync(userA, roleA, Now);
+ await store.AssignAsync(userB, roleB, Now);
+
+ var result = await store.GetAssignmentsAsync(userA);
+
+ result.Should().ContainSingle();
+ result.Single().UserKey.Should().Be(userA);
+ result.Single().RoleId.Should().Be(roleA);
+ }
+
+ [Fact]
+ public async Task GetAssignmentsAsync_WhenUserHasNoAssignments_ReturnsEmpty()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var result = await store.GetAssignmentsAsync(UserKey.New());
+
+ result.Should().BeEmpty();
+ }
+
+ // ---------------------------------------------------------
+ // Remove
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task RemoveAsync_WhenAssignmentExists_RemovesOnlyRequestedAssignment()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+
+ var roleA = RoleId.New();
+ var roleB = RoleId.New();
+
+ await store.AssignAsync(user, roleA, Now);
+ await store.AssignAsync(user, roleB, Now);
+
+ await store.RemoveAsync(user, roleA);
+
+ var result = await store.GetAssignmentsAsync(user);
+
+ result.Should().ContainSingle();
+ result.Single().RoleId.Should().Be(roleB);
+ }
+
+ [Fact]
+ public async Task RemoveAsync_WhenAssignmentDoesNotExist_IsIdempotent()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var user = UserKey.New();
+ var roleId = RoleId.New();
+
+ var act = () => store.RemoveAsync(user, roleId);
+
+ await act.Should().NotThrowAsync();
+
+ var result = await store.GetAssignmentsAsync(user);
+
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DoesNotRemoveSameRoleFromOtherUser()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var userA = UserKey.New();
+ var userB = UserKey.New();
+ var roleId = RoleId.New();
+
+ await store.AssignAsync(userA, roleId, Now);
+ await store.AssignAsync(userB, roleId, Now);
+
+ await store.RemoveAsync(userA, roleId);
+
+ var assignmentsA = await store.GetAssignmentsAsync(userA);
+ var assignmentsB = await store.GetAssignmentsAsync(userB);
+
+ assignmentsA.Should().BeEmpty();
+
+ assignmentsB.Should().ContainSingle();
+ assignmentsB.Single().RoleId.Should().Be(roleId);
+ }
+
+ // ---------------------------------------------------------
+ // Remove by role
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task RemoveAssignmentsByRoleAsync_RemovesRoleFromAllUsers()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var userA = UserKey.New();
+ var userB = UserKey.New();
+
+ var targetRole = RoleId.New();
+ var otherRole = RoleId.New();
+
+ await store.AssignAsync(userA, targetRole, Now);
+ await store.AssignAsync(userA, otherRole, Now);
+ await store.AssignAsync(userB, targetRole, Now);
+
+ await store.RemoveAssignmentsByRoleAsync(targetRole);
+
+ var assignmentsA = await store.GetAssignmentsAsync(userA);
+ var assignmentsB = await store.GetAssignmentsAsync(userB);
+
+ assignmentsA.Should().ContainSingle();
+ assignmentsA.Single().RoleId.Should().Be(otherRole);
+
+ assignmentsB.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task RemoveAssignmentsByRoleAsync_WhenNoAssignmentsExist_IsIdempotent()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var act = () =>
+ store.RemoveAssignmentsByRoleAsync(RoleId.New());
+
+ await act.Should().NotThrowAsync();
+ }
+
+ // ---------------------------------------------------------
+ // Count
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task CountAssignmentsAsync_ReturnsNumberOfAssignmentsForRole()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var roleA = RoleId.New();
+ var roleB = RoleId.New();
+
+ await store.AssignAsync(UserKey.New(), roleA, Now);
+ await store.AssignAsync(UserKey.New(), roleA, Now);
+ await store.AssignAsync(UserKey.New(), roleA, Now);
+ await store.AssignAsync(UserKey.New(), roleB, Now);
+
+ var result = await store.CountAssignmentsAsync(roleA);
+
+ result.Should().Be(3);
+ }
+
+ [Fact]
+ public async Task CountAssignmentsAsync_WhenNoAssignmentsExist_ReturnsZero()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ var result =
+ await store.CountAssignmentsAsync(RoleId.New());
+
+ result.Should().Be(0);
+ }
+
+ // ---------------------------------------------------------
+ // Tenant isolation
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task GetAssignmentsAsync_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var user = UserKey.New();
+ var roleId = RoleId.New();
+
+ await storeA.AssignAsync(user, roleId, Now);
+
+ var fromA = await storeA.GetAssignmentsAsync(user);
+ var fromB = await storeB.GetAssignmentsAsync(user);
+
+ fromA.Should().ContainSingle();
+ fromB.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task CountAssignmentsAsync_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var roleId = RoleId.New();
+
+ await storeA.AssignAsync(UserKey.New(), roleId, Now);
+ await storeA.AssignAsync(UserKey.New(), roleId, Now);
+
+ await storeB.AssignAsync(UserKey.New(), roleId, Now);
+
+ var countA = await storeA.CountAssignmentsAsync(roleId);
+ var countB = await storeB.CountAssignmentsAsync(roleId);
+
+ countA.Should().Be(2);
+ countB.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task RemoveAssignmentsByRoleAsync_IsTenantIsolated()
+ {
+ await using var db = await CreateDatabaseAsync();
+
+ var tenantA = TestIds.Tenant("tenant-a");
+ var tenantB = TestIds.Tenant("tenant-b");
+
+ var storeA = db.CreateStore(tenantA);
+ var storeB = db.CreateStore(tenantB);
+
+ var roleId = RoleId.New();
+
+ var userA = UserKey.New();
+ var userB = UserKey.New();
+
+ await storeA.AssignAsync(userA, roleId, Now);
+ await storeB.AssignAsync(userB, roleId, Now);
+
+ await storeA.RemoveAssignmentsByRoleAsync(roleId);
+
+ var assignmentsA =
+ await storeA.GetAssignmentsAsync(userA);
+
+ var assignmentsB =
+ await storeB.GetAssignmentsAsync(userB);
+
+ assignmentsA.Should().BeEmpty();
+
+ assignmentsB.Should().ContainSingle();
+ assignmentsB.Single().RoleId.Should().Be(roleId);
+ }
+
+ // ---------------------------------------------------------
+ // Cancellation
+ // ---------------------------------------------------------
+
+ [Fact]
+ public async Task Operations_WhenCancellationRequested_ThrowOperationCanceledException()
+ {
+ await using var db = await CreateDatabaseAsync();
+ var store = db.CreateStore(Tenant);
+
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var user = UserKey.New();
+ var roleId = RoleId.New();
+
+ var get = () =>
+ store.GetAssignmentsAsync(user, cts.Token);
+
+ var assign = () =>
+ store.AssignAsync(user, roleId, Now, cts.Token);
+
+ var remove = () =>
+ store.RemoveAsync(user, roleId, cts.Token);
+
+ var removeByRole = () =>
+ store.RemoveAssignmentsByRoleAsync(roleId, cts.Token);
+
+ var count = () =>
+ store.CountAssignmentsAsync(roleId, cts.Token);
+
+ await get.Should().ThrowAsync();
+ await assign.Should().ThrowAsync();
+ await remove.Should().ThrowAsync();
+ await removeByRole.Should().ThrowAsync();
+ await count.Should().ThrowAsync();
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs
new file mode 100644
index 00000000..54ee688b
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs
@@ -0,0 +1,996 @@
+ο»Ώusing CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Authorization.Reference;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using FluentAssertions;
+using Microsoft.AspNetCore.Identity;
+using Moq;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit;
+
+public sealed class UserRoleServiceTests
+{
+ private static readonly DateTimeOffset Now =
+ new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero);
+
+ // =========================================================
+ // Assign
+ // =========================================================
+
+ [Fact]
+ public async Task AssignAsync_WhenRoleExists_NormalizesRoleNameAndAssignsTargetUser()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.assign");
+ var target = UserKey.New();
+
+ var role = f.Role("Administrators");
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "ADMINISTRATORS",
+ It.IsAny()))
+ .ReturnsAsync(role);
+
+ f.UserRoleStore
+ .Setup(x => x.AssignAsync(
+ target,
+ role.Id,
+ Now,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.AssignAsync(
+ context,
+ target,
+ " administrators ");
+
+ f.RoleStore.Verify(x => x.GetByNameAsync(
+ "ADMINISTRATORS",
+ It.IsAny()),
+ Times.Once);
+
+ f.UserRoleStore.Verify(x => x.AssignAsync(
+ target,
+ role.Id,
+ Now,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task AssignAsync_WhenRoleDoesNotExist_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.assign");
+ var target = UserKey.New();
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "MISSING",
+ It.IsAny()))
+ .ReturnsAsync((Role?)null);
+
+ var act = () => f.Sut.AssignAsync(
+ context,
+ target,
+ "missing");
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+
+ f.UserRoleStore.Verify(
+ x => x.AssignAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task AssignAsync_WhenRoleIsDeleted_ThrowsNotFound()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.assign");
+ var target = UserKey.New();
+
+ var role = f.Role("Admin");
+ role.MarkDeleted(Now.AddMinutes(-1));
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "ADMIN",
+ It.IsAny()))
+ .ReturnsAsync(role);
+
+ var act = () => f.Sut.AssignAsync(
+ context,
+ target,
+ "admin");
+
+ var exception = await act.Should()
+ .ThrowAsync();
+
+ exception.Which.Code.Should().Be("role_not_found");
+
+ f.UserRoleStore.Verify(
+ x => x.AssignAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task AssignAsync_UsesResourceTenantForBothStores()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.assign");
+ var target = UserKey.New();
+ var role = f.Role("Admin");
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "ADMIN",
+ It.IsAny()))
+ .ReturnsAsync(role);
+
+ f.UserRoleStore
+ .Setup(x => x.AssignAsync(
+ target,
+ role.Id,
+ Now,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.AssignAsync(
+ context,
+ target,
+ "Admin");
+
+ f.RoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+
+ f.UserRoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Remove
+ // =========================================================
+
+ [Fact]
+ public async Task RemoveAsync_WhenRoleExists_NormalizesRoleNameAndRemovesAssignment()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.remove");
+ var target = UserKey.New();
+
+ var role = f.Role("Operators");
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "OPERATORS",
+ It.IsAny()))
+ .ReturnsAsync(role);
+
+ f.UserRoleStore
+ .Setup(x => x.RemoveAsync(
+ target,
+ role.Id,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.RemoveAsync(
+ context,
+ target,
+ " operators ");
+
+ f.UserRoleStore.Verify(x => x.RemoveAsync(
+ target,
+ role.Id,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task RemoveAsync_WhenRoleDoesNotExist_IsIdempotent()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.remove");
+ var target = UserKey.New();
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "MISSING",
+ It.IsAny()))
+ .ReturnsAsync((Role?)null);
+
+ var act = () => f.Sut.RemoveAsync(
+ context,
+ target,
+ "missing");
+
+ await act.Should().NotThrowAsync();
+
+ f.UserRoleStore.Verify(
+ x => x.RemoveAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task RemoveAsync_WhenRoleIsDeleted_StillRemovesAssignment()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.remove");
+ var target = UserKey.New();
+
+ var role = f.Role("Legacy");
+ role.MarkDeleted(Now.AddMinutes(-5));
+
+ f.RoleStore
+ .Setup(x => x.GetByNameAsync(
+ "LEGACY",
+ It.IsAny()))
+ .ReturnsAsync(role);
+
+ f.UserRoleStore
+ .Setup(x => x.RemoveAsync(
+ target,
+ role.Id,
+ It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await f.Sut.RemoveAsync(
+ context,
+ target,
+ "legacy");
+
+ f.UserRoleStore.Verify(x => x.RemoveAsync(
+ target,
+ role.Id,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ // =========================================================
+ // GetRoles
+ // =========================================================
+
+ [Fact]
+ public async Task GetRolesAsync_WhenAssignmentsExist_JoinsAssignmentsWithRoles()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var admin = f.Role("Admin");
+ var auditor = f.Role("Auditor");
+
+ var adminAssignedAt = Now.AddDays(-10);
+ var auditorAssignedAt = Now.AddDays(-5);
+
+ var assignments = new[]
+ {
+ f.Assignment(target, admin.Id, adminAssignedAt),
+ f.Assignment(target, auditor.Id, auditorAssignedAt)
+ };
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(assignments);
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.Is>(ids =>
+ ids.Count == 2 &&
+ ids.Contains(admin.Id) &&
+ ids.Contains(auditor.Id)),
+ It.IsAny()))
+ .ReturnsAsync(new[] { admin, auditor });
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest());
+
+ result.TotalCount.Should().Be(2);
+ result.Items.Should().HaveCount(2);
+
+ result.Items.Should().ContainEquivalentOf(
+ new UserRoleInfo
+ {
+ Tenant = context.ResourceTenant,
+ UserKey = target,
+ RoleId = admin.Id,
+ Name = "Admin",
+ AssignedAt = adminAssignedAt
+ });
+
+ result.Items.Should().ContainEquivalentOf(
+ new UserRoleInfo
+ {
+ Tenant = context.ResourceTenant,
+ UserKey = target,
+ RoleId = auditor.Id,
+ Name = "Auditor",
+ AssignedAt = auditorAssignedAt
+ });
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenAssignmentReferencesMissingRole_IgnoresOrphanAssignment()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var existingRole = f.Role("Admin");
+ var missingRoleId = RoleId.New();
+
+ var assignments = new[]
+ {
+ f.Assignment(
+ target,
+ existingRole.Id,
+ Now.AddDays(-2)),
+
+ f.Assignment(
+ target,
+ missingRoleId,
+ Now.AddDays(-1))
+ };
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(assignments);
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new[] { existingRole });
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest());
+
+ result.TotalCount.Should().Be(1);
+ result.Items.Should().ContainSingle();
+
+ result.Items[0].RoleId.Should().Be(existingRole.Id);
+ result.Items[0].Name.Should().Be("Admin");
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_AppliesPaginationAfterJoin()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var role1 = f.Role("Role 1");
+ var role2 = f.Role("Role 2");
+ var role3 = f.Role("Role 3");
+
+ var assignments = new[]
+ {
+ f.Assignment(target, role1.Id, Now.AddDays(-3)),
+ f.Assignment(target, role2.Id, Now.AddDays(-2)),
+ f.Assignment(target, role3.Id, Now.AddDays(-1))
+ };
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(assignments);
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new[] { role1, role2, role3 });
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ PageNumber = 2,
+ PageSize = 1
+ });
+
+ result.TotalCount.Should().Be(3);
+ result.PageNumber.Should().Be(2);
+ result.PageSize.Should().Be(1);
+
+ result.Items.Should().ContainSingle();
+ result.Items[0].RoleId.Should().Be(role2.Id);
+
+ result.HasNext.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_NormalizesInvalidPagingValues()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.Is>(ids => ids.Count == 0),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ PageNumber = -10,
+ PageSize = 0
+ });
+
+ result.PageNumber.Should().Be(1);
+ result.PageSize.Should().Be(250);
+ result.TotalCount.Should().Be(0);
+ result.Items.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ PageNumber = 1,
+ PageSize = 5000,
+ MaxPageSize = 100
+ });
+
+ result.PageSize.Should().Be(100);
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_PreservesPagingMetadata()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ PageNumber = 3,
+ PageSize = 20,
+ SortBy = "name",
+ Descending = true
+ });
+
+ result.PageNumber.Should().Be(3);
+ result.PageSize.Should().Be(20);
+ result.SortBy.Should().Be("name");
+ result.Descending.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_UsesResourceTenantForBothStores()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ f.UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ f.RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest());
+
+ f.RoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+
+ f.UserRoleFactory.Verify(
+ x => x.Create(context.ResourceTenant),
+ Times.Once);
+ }
+
+ // =========================================================
+ // Cancellation
+ // =========================================================
+
+ [Fact]
+ public async Task AssignAsync_WhenAlreadyCancelled_DoesNotExecuteAccessCommand()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.assign");
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var act = () => f.Sut.AssignAsync(
+ context,
+ UserKey.New(),
+ "Admin",
+ cts.Token);
+
+ await act.Should()
+ .ThrowAsync();
+
+ f.AccessOrchestrator.VerifyNoOtherCalls();
+ f.RoleFactory.VerifyNoOtherCalls();
+ f.UserRoleFactory.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenSortByNameAscending_SortsByName()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var charlie = f.Role("Charlie");
+ var alpha = f.Role("Alpha");
+ var bravo = f.Role("Bravo");
+
+ // Deliberately not alphabetic.
+ var assignments = new[]
+ {
+ f.Assignment(target, charlie.Id, Now.AddDays(-3)),
+ f.Assignment(target, alpha.Id, Now.AddDays(-2)),
+ f.Assignment(target, bravo.Id, Now.AddDays(-1))
+ };
+
+ f.SetupGetRoles(target, assignments, charlie, alpha, bravo);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ SortBy = nameof(UserRoleInfo.Name),
+ Descending = false
+ });
+
+ result.Items
+ .Select(x => x.Name)
+ .Should()
+ .ContainInOrder("Alpha", "Bravo", "Charlie");
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenSortByNameDescending_SortsByNameDescending()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var bravo = f.Role("Bravo");
+ var charlie = f.Role("Charlie");
+ var alpha = f.Role("Alpha");
+
+ var assignments = new[]
+ {
+ f.Assignment(target, bravo.Id, Now.AddDays(-3)),
+ f.Assignment(target, charlie.Id, Now.AddDays(-2)),
+ f.Assignment(target, alpha.Id, Now.AddDays(-1))
+ };
+
+ f.SetupGetRoles(target, assignments, bravo, charlie, alpha);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ SortBy = nameof(UserRoleInfo.Name),
+ Descending = true
+ });
+
+ result.Items
+ .Select(x => x.Name)
+ .Should()
+ .ContainInOrder("Charlie", "Bravo", "Alpha");
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenSortByAssignedAtAscending_SortsByAssignedAt()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var role1 = f.Role("Role 1");
+ var role2 = f.Role("Role 2");
+ var role3 = f.Role("Role 3");
+
+ var oldest = Now.AddDays(-10);
+ var middle = Now.AddDays(-5);
+ var newest = Now.AddDays(-1);
+
+ var assignments = new[]
+ {
+ f.Assignment(target, role1.Id, newest),
+ f.Assignment(target, role2.Id, oldest),
+ f.Assignment(target, role3.Id, middle)
+ };
+
+ f.SetupGetRoles(target, assignments, role1, role2, role3);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ SortBy = nameof(UserRoleInfo.AssignedAt),
+ Descending = false
+ });
+
+ result.Items
+ .Select(x => x.AssignedAt)
+ .Should()
+ .ContainInOrder(oldest, middle, newest);
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenSortByAssignedAtDescending_SortsByAssignedAtDescending()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var role1 = f.Role("Role 1");
+ var role2 = f.Role("Role 2");
+ var role3 = f.Role("Role 3");
+
+ var oldest = Now.AddDays(-10);
+ var middle = Now.AddDays(-5);
+ var newest = Now.AddDays(-1);
+
+ var assignments = new[]
+ {
+ f.Assignment(target, role1.Id, middle),
+ f.Assignment(target, role2.Id, oldest),
+ f.Assignment(target, role3.Id, newest)
+ };
+
+ f.SetupGetRoles(target, assignments, role1, role2, role3);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ SortBy = nameof(UserRoleInfo.AssignedAt),
+ Descending = true
+ });
+
+ result.Items
+ .Select(x => x.AssignedAt)
+ .Should()
+ .ContainInOrder(newest, middle, oldest);
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenSortByIsNotSpecified_DefaultsToNameAscending()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var zebra = f.Role("Zebra");
+ var admin = f.Role("Admin");
+ var manager = f.Role("Manager");
+
+ var assignments = new[]
+ {
+ f.Assignment(target, zebra.Id, Now.AddDays(-3)),
+ f.Assignment(target, admin.Id, Now.AddDays(-2)),
+ f.Assignment(target, manager.Id, Now.AddDays(-1))
+ };
+
+ f.SetupGetRoles(target, assignments, zebra, admin, manager);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest());
+
+ result.Items
+ .Select(x => x.Name)
+ .Should()
+ .ContainInOrder("Admin", "Manager", "Zebra");
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_SortsBeforeApplyingPagination()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var charlie = f.Role("Charlie");
+ var alpha = f.Role("Alpha");
+ var echo = f.Role("Echo");
+ var bravo = f.Role("Bravo");
+ var delta = f.Role("Delta");
+
+ // Deliberately:
+ // Charlie, Alpha, Echo, Bravo, Delta
+ //
+ // Correct sorted result:
+ // Alpha, Bravo, Charlie, Delta, Echo
+ var assignments = new[]
+ {
+ f.Assignment(target, charlie.Id, Now.AddMinutes(-5)),
+ f.Assignment(target, alpha.Id, Now.AddMinutes(-4)),
+ f.Assignment(target, echo.Id, Now.AddMinutes(-3)),
+ f.Assignment(target, bravo.Id, Now.AddMinutes(-2)),
+ f.Assignment(target, delta.Id, Now.AddMinutes(-1))
+ };
+
+ f.SetupGetRoles(
+ target,
+ assignments,
+ charlie,
+ alpha,
+ echo,
+ bravo,
+ delta);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ PageNumber = 2,
+ PageSize = 2,
+ SortBy = nameof(UserRoleInfo.Name)
+ });
+
+ result.TotalCount.Should().Be(5);
+ result.PageNumber.Should().Be(2);
+ result.PageSize.Should().Be(2);
+
+ result.Items
+ .Select(x => x.Name)
+ .Should()
+ .ContainInOrder("Charlie", "Delta");
+ }
+
+ [Fact]
+ public async Task GetRolesAsync_WhenNamesAreEqual_UsesRoleIdAsDeterministicTieBreaker()
+ {
+ var f = new Fixture();
+ var context = f.Context("roles.list");
+ var target = UserKey.New();
+
+ var lowerId = Role.FromProjection(
+ RoleId.From(Guid.Parse("00000000-0000-0000-0000-000000000001")),
+ TenantKey.Single,
+ "Admin",
+ [],
+ Now.AddDays(-2),
+ null,
+ null,
+ 0);
+
+ var higherId = Role.FromProjection(
+ RoleId.From(Guid.Parse("00000000-0000-0000-0000-000000000002")),
+ TenantKey.Single,
+ "Admin",
+ [],
+ Now.AddDays(-1),
+ null,
+ null,
+ 0);
+
+ // Reverse storage order deliberately.
+ var assignments = new[]
+ {
+ f.Assignment(target, higherId.Id, Now),
+ f.Assignment(target, lowerId.Id, Now)
+ };
+
+ f.SetupGetRoles(
+ target,
+ assignments,
+ higherId,
+ lowerId);
+
+ var result = await f.Sut.GetRolesAsync(
+ context,
+ target,
+ new PageRequest
+ {
+ SortBy = nameof(UserRoleInfo.Name)
+ });
+
+ result.Items.Select(x => x.RoleId)
+ .Should()
+ .ContainInOrder(lowerId.Id, higherId.Id);
+ }
+
+ // =========================================================
+ // Fixture
+ // =========================================================
+
+ private sealed class Fixture
+ {
+ public Mock AccessOrchestrator { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock UserRoleFactory { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock UserRoleStore { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock RoleFactory { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock RoleStore { get; }
+ = new(MockBehavior.Strict);
+
+ public Mock Clock { get; }
+ = new(MockBehavior.Strict);
+
+ public UserRoleService Sut { get; }
+
+ public Fixture()
+ {
+ Clock
+ .SetupGet(x => x.UtcNow)
+ .Returns(Now);
+
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ AccessOrchestrator
+ .Setup(x => x.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny>>(),
+ It.IsAny()))
+ .Returns<
+ AccessContext,
+ AccessCommand>,
+ CancellationToken>(
+ async (_, command, ct) =>
+ await command.ExecuteAsync(ct));
+
+ UserRoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(UserRoleStore.Object);
+
+ RoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(RoleStore.Object);
+
+ Sut = new UserRoleService(
+ AccessOrchestrator.Object,
+ UserRoleFactory.Object,
+ RoleFactory.Object,
+ Clock.Object);
+ }
+
+ public AccessContext Context(string action)
+ => TestAccessContext.WithAction(action);
+
+ public Role Role(string name)
+ => global::CodeBeam.UltimateAuth.Authorization.Role.Create(
+ RoleId.New(),
+ TenantKey.Single,
+ name,
+ permissions: null,
+ now: Now.AddHours(-1));
+
+ public UserRole Assignment(
+ UserKey user,
+ RoleId roleId,
+ DateTimeOffset assignedAt)
+ => new()
+ {
+ Tenant = TenantKey.Single,
+ UserKey = user,
+ RoleId = roleId,
+ AssignedAt = assignedAt
+ };
+
+ public void SetupGetRoles(
+ UserKey target,
+ IReadOnlyCollection assignments,
+ params Role[] roles)
+ {
+ UserRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ target,
+ It.IsAny()))
+ .ReturnsAsync(assignments);
+
+ RoleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.Is>(ids =>
+ ids.Count == assignments.Count &&
+ assignments.All(a => ids.Contains(a.RoleId))),
+ It.IsAny()))
+ .ReturnsAsync(roles);
+ }
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs
index e53b5840..277146e7 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs
@@ -1,15 +1,14 @@
ο»Ώusing Bunit;
-using CodeBeam.UltimateAuth.Authorization.Contracts;
using CodeBeam.UltimateAuth.Authorization.Reference;
using CodeBeam.UltimateAuth.Client;
using CodeBeam.UltimateAuth.Client.Blazor;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
using FluentAssertions;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Moq;
-using System.Security.Claims;
namespace CodeBeam.UltimateAuth.Tests.Unit;
@@ -113,7 +112,7 @@ public void Should_Require_All_When_MatchAll_True()
var cut = RenderWithAuth(ctx, state, p => p
.Add(x => x.Roles, "admin,user")
- .Add(x => x.MatchAll, true)
+ .Add(x => x.MatchMode, AuthorizationMatchMode.All)
.Add(x => x.NotAuthorized, Html("no
"))
);
@@ -129,7 +128,47 @@ public void Should_Allow_Any_When_MatchAll_False()
var cut = RenderWithAuth(ctx, state, p => p
.Add(x => x.Roles, "admin,user")
- .Add(x => x.MatchAll, false)
+ .Add(x => x.MatchMode, AuthorizationMatchMode.Any)
+ .Add(x => x.Authorized, s => b => b.AddContent(0, "ok"))
+ );
+
+ cut.Markup.Should().Contain("ok");
+ }
+
+ [Fact]
+ public void Should_Fail_When_One_Category_Does_Not_Match_In_Category_Mode()
+ {
+ using var ctx = new BunitContext();
+
+ var state = TestAuthState.WithRoles("admin");
+
+ ctx.Services.AddSingleton(Mock.Of());
+
+ var cut = RenderWithAuth(ctx, state, p => p
+ .Add(x => x.Roles, "admin,user")
+ .Add(x => x.Permissions, "write")
+ .Add(x => x.MatchMode, AuthorizationMatchMode.Category)
+ .Add(x => x.NotAuthorized, Html("no
"))
+ );
+
+ cut.Markup.Should().Contain("no");
+ }
+
+ [Fact]
+ public void Should_Require_At_Least_One_Match_Per_Category_When_MatchMode_Is_Category()
+ {
+ using var ctx = new BunitContext();
+
+ var state = TestAuthState.Create(
+ roles: ["admin"],
+ permissions: ["write"]);
+
+ ctx.Services.AddSingleton(Mock.Of());
+
+ var cut = RenderWithAuth(ctx, state, p => p
+ .Add(x => x.Roles, "admin,user")
+ .Add(x => x.Permissions, "write,delete")
+ .Add(x => x.MatchMode, AuthorizationMatchMode.Category)
.Add(x => x.Authorized, s => b => b.AddContent(0, "ok"))
);
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs
new file mode 100644
index 00000000..dc12cb93
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs
@@ -0,0 +1,281 @@
+ο»Ώusing Bunit;
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Abstractions;
+using CodeBeam.UltimateAuth.Client.Blazor;
+using CodeBeam.UltimateAuth.Client.Contracts;
+using CodeBeam.UltimateAuth.Client.Infrastructure;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using FluentAssertions;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using System.Security.Claims;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor;
+
+public sealed class UAuthAppLifecycleTests : BunitContext
+{
+ private readonly Mock _stateManager = new();
+ private readonly Mock _bootstrapper = new();
+ private readonly Mock _coordinator = new();
+
+ private readonly UAuthState _state = UAuthState.Anonymous();
+
+ public UAuthAppLifecycleTests()
+ {
+ this.AddAuthorization();
+
+ _stateManager
+ .SetupGet(x => x.State)
+ .Returns(_state);
+
+ _bootstrapper
+ .Setup(x => x.EnsureStartedAsync())
+ .Returns(Task.CompletedTask);
+
+ _stateManager
+ .Setup(x => x.EnsureAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ _coordinator
+ .Setup(x => x.StartAsync())
+ .Returns(Task.CompletedTask);
+
+ _coordinator
+ .Setup(x => x.StopAsync())
+ .Returns(Task.CompletedTask);
+
+ Services.AddSingleton(_stateManager.Object);
+ Services.AddSingleton(_bootstrapper.Object);
+ Services.AddSingleton(_coordinator.Object);
+ }
+
+ [Fact]
+ public void FirstRender_StartsBootstrapperAndEnsuresStateExactlyOnce()
+ {
+ var cut = Render(p => p
+ .AddChildContent("content
"));
+
+ cut.WaitForAssertion(() =>
+ {
+ _bootstrapper.Verify(
+ x => x.EnsureStartedAsync(),
+ Times.Once);
+
+ _stateManager.Verify(
+ x => x.EnsureAsync(false),
+ Times.Once);
+ });
+ }
+
+ [Fact]
+ public void FirstRender_BootstrapsBeforeEnsuringState()
+ {
+ var sequence = new MockSequence();
+
+ _bootstrapper
+ .InSequence(sequence)
+ .Setup(x => x.EnsureStartedAsync())
+ .Returns(Task.CompletedTask);
+
+ _stateManager
+ .InSequence(sequence)
+ .Setup(x => x.EnsureAsync(false))
+ .Returns(Task.CompletedTask);
+
+ Render(p => p
+ .AddChildContent("content"));
+ }
+
+ [Fact]
+ public void FirstRender_WhenAnonymous_DoesNotStartCoordinator()
+ {
+ Render(p => p
+ .AddChildContent("content"));
+
+ _coordinator.Verify(
+ x => x.StartAsync(),
+ Times.Never);
+ }
+
+ [Fact]
+ public void FirstRender_WhenAuthenticated_StartsCoordinator()
+ {
+ var state = AuthenticatedState();
+
+ _stateManager
+ .SetupGet(x => x.State)
+ .Returns(state);
+
+ Render(p => p
+ .AddChildContent("content"));
+
+ _coordinator.Verify(
+ x => x.StartAsync(),
+ Times.Once);
+ }
+
+ [Fact]
+ public void AuthenticatedStateChange_StartsCoordinator()
+ {
+ var cut = Render(p => p
+ .AddChildContent("content"));
+
+ _coordinator.Invocations.Clear();
+
+ ApplyAuthenticatedSnapshot(_state);
+
+ cut.WaitForAssertion(() =>
+ {
+ _coordinator.Verify(
+ x => x.StartAsync(),
+ Times.Once);
+ });
+ }
+
+ [Fact]
+ public void WhenStateNeedsValidation_ForcesEnsure()
+ {
+ var state = AuthenticatedState();
+ state.MarkStale();
+
+ _stateManager
+ .SetupGet(x => x.State)
+ .Returns(state);
+
+ Render(p => p
+ .AddChildContent("content"));
+
+ _stateManager.Verify(
+ x => x.EnsureAsync(true),
+ Times.AtLeastOnce);
+ }
+
+ [Fact]
+ public void ReauthRequired_MarksStateStale()
+ {
+ var state = AuthenticatedState();
+
+ _stateManager
+ .SetupGet(x => x.State)
+ .Returns(state);
+
+ Render(p => p
+ .AddChildContent("content"));
+
+ _coordinator.Raise(x => x.ReauthRequired += null);
+
+ _stateManager.Verify(
+ x => x.MarkStale(),
+ Times.Once);
+ }
+
+ [Fact]
+ public void ReauthRequired_InvokesConsumerCallback()
+ {
+ var callbackCount = 0;
+
+ var cut = Render(p => p
+ .Add(x => x.OnReauthRequired,
+ EventCallback.Factory.Create(
+ this,
+ () => callbackCount++))
+ .AddChildContent("content"));
+
+ _coordinator.Raise(x => x.ReauthRequired += null);
+
+ cut.WaitForAssertion(() => callbackCount.Should().Be(1));
+ }
+
+ [Fact]
+ public async Task Dispose_StopsStartedCoordinator()
+ {
+ var state = AuthenticatedState();
+
+ _stateManager
+ .SetupGet(x => x.State)
+ .Returns(state);
+
+ var cut = Render(p => p
+ .AddChildContent("content"));
+
+ _coordinator.Invocations.Clear();
+
+ await cut.Instance.DisposeAsync();
+
+ _coordinator.Verify(
+ x => x.StopAsync(),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Dispose_WhenCoordinatorStartedAfterAuthentication_StopsCoordinator()
+ {
+ var cut = Render(p => p
+ .AddChildContent("content"));
+
+ ApplyAuthenticatedSnapshot(_state);
+
+ cut.WaitForAssertion(() =>
+ {
+ _coordinator.Verify(
+ x => x.StartAsync(),
+ Times.Once);
+ });
+
+ _coordinator.Invocations.Clear();
+
+ await cut.Instance.DisposeAsync();
+
+ _coordinator.Verify(
+ x => x.StopAsync(),
+ Times.Once);
+ }
+
+ private static UAuthState AuthenticatedState(
+ string userKey = "user-1",
+ string? userName = "alice",
+ SessionState sessionState = SessionState.Active)
+ {
+ var state = UAuthState.Anonymous();
+
+ state.ApplySnapshot(
+ CreateAuthSnapshot(
+ userKey,
+ userName,
+ sessionState),
+ DateTimeOffset.UtcNow);
+
+ return state;
+ }
+
+ private static AuthStateSnapshot CreateAuthSnapshot(
+ string userKey = "user-1",
+ string? userName = "alice",
+ SessionState sessionState = SessionState.Active)
+ {
+ return new AuthStateSnapshot
+ {
+ Identity = new AuthIdentitySnapshot
+ {
+ UserKey = UserKey.FromString(userKey),
+ Tenant = TenantKeys.Single,
+ PrimaryUserName = userName,
+ DisplayName = userName,
+ SessionState = sessionState,
+ UserStatus = UserStatus.Active
+ },
+ Claims = ClaimsSnapshot.From(
+ (ClaimTypes.Role, "User"))
+ };
+ }
+
+ private static void ApplyAuthenticatedSnapshot(UAuthState state)
+ {
+ state.ApplySnapshot(
+ CreateAuthSnapshot(),
+ DateTimeOffset.UtcNow);
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs
new file mode 100644
index 00000000..29e4837c
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs
@@ -0,0 +1,406 @@
+ο»Ώusing Bunit;
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Client.Blazor;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using FluentAssertions;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using System.Security.Claims;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor;
+
+public sealed class UAuthComponentBaseTests : BunitContext
+{
+ [Fact]
+ public void Render_WithoutUAuthState_ThrowsInvalidOperationException()
+ {
+ var act = () => Render();
+
+ act.Should()
+ .Throw()
+ .WithMessage("*requires a cascading parameter*UAuthState*");
+ }
+
+ [Fact]
+ public void Render_WithUAuthState_ReceivesCascadingState()
+ {
+ var state = UAuthState.Anonymous();
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.CurrentAuthState
+ .Should()
+ .BeSameAs(state);
+ }
+
+ [Fact]
+ public void Render_WithoutAuthorizeAttribute_DoesNotCallUnauthorized()
+ {
+ var state = UAuthState.Anonymous();
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void AuthStateCleared_WithRoleRequirement_WhenAnonymous_CallsUnauthorized_NotForbidden()
+ {
+ var state = UAuthState.Anonymous();
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ // Initial render must not make a premature authorization decision.
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+
+ state.Clear();
+
+ component.UnauthorizedCount.Should().Be(1);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void Dispose_DoesNotThrow()
+ {
+ var state = UAuthState.Anonymous();
+
+ var cut = RenderWithState(state);
+
+ var act = () => cut.Dispose();
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Render_WithAuthorizeAttribute_WhenAuthenticated_DoesNotReject()
+ {
+ var state = AuthenticatedState();
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void Render_WithRequiredRole_WhenUserHasRole_DoesNotReject()
+ {
+ var state = AuthenticatedState(
+ roles: ["admin"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void AuthStateChanged_WithRequiredRole_WhenUserDoesNotHaveRole_CallsForbidden()
+ {
+ var state = AuthenticatedState(
+ roles: ["User"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+
+ state.MarkStale();
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void Render_WithRequiredPermission_WhenUserHasPermission_DoesNotReject()
+ {
+ var state = AuthenticatedState(
+ permissions: ["users.read"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void AuthStateChanged_WithRequiredPermission_WhenUserDoesNotHavePermission_CallsForbidden()
+ {
+ var state = AuthenticatedState(
+ permissions: ["users.list"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ // No premature decision during initial render.
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+
+ state.MarkStale();
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(1);
+ }
+
+ [Fact]
+ public void Render_WithMultipleRoles_AllowsWhenAnyRoleMatches()
+ {
+ var state = AuthenticatedState(
+ roles: ["manager"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void Render_WithMultiplePermissions_AllowsWhenAnyPermissionMatches()
+ {
+ var state = AuthenticatedState(
+ permissions: ["users.write"]);
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ component.UnauthorizedCount.Should().Be(0);
+ component.ForbiddenCount.Should().Be(0);
+ }
+
+ [Fact]
+ public void StateChange_ForwardsReasonToComponent()
+ {
+ var state = AuthenticatedState();
+
+ var cut = RenderWithState(state);
+
+ var component = cut
+ .FindComponent()
+ .Instance;
+
+ state.MarkStale();
+
+ component.StateChangedCount.Should().Be(1);
+ component.LastChangeReason
+ .Should().Be(UAuthStateChangeReason.MarkedStale);
+ }
+
+ [Fact]
+ public void StateChange_ReevaluatesAuthorization()
+ {
+ var state = AuthenticatedState();
+
+ var cut =
+ RenderWithState(state);
+
+ var component = cut
+ .FindComponent