Complete(string tenantId, CancellationToken cancellationToken)
+ {
+ var result = await tenants.CompleteSetupAsync(tenantId, cancellationToken);
+ return Ok(result);
+ }
+}
From c4e056d57218abaabbf4b7c98f51b5d3dbbe4316 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:18:39 -0300
Subject: [PATCH 023/112] feat: add system setup razor page
---
.../Components/Pages/Setup.razor | 63 +++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 src/server/RemoteTickets/Components/Pages/Setup.razor
diff --git a/src/server/RemoteTickets/Components/Pages/Setup.razor b/src/server/RemoteTickets/Components/Pages/Setup.razor
new file mode 100644
index 0000000..93cd0b6
--- /dev/null
+++ b/src/server/RemoteTickets/Components/Pages/Setup.razor
@@ -0,0 +1,63 @@
+@page "/setup"
+@using System.Net.Http.Json
+@inject IHttpClientFactory HttpClientFactory
+@inject NavigationManager Navigation
+
+System setup
+
+System setup
+Configure the master SQL Server database and create the first system administrator before using the application.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@if (!string.IsNullOrWhiteSpace(Message))
+{
+ @Message
+}
+
+@code {
+ private bool Busy;
+ private string ConnectionString = string.Empty;
+ private string Email = string.Empty;
+ private string Password = string.Empty;
+ private string Message = string.Empty;
+
+ private async Task InitializeAsync()
+ {
+ Busy = true;
+ Message = string.Empty;
+ try
+ {
+ var client = HttpClientFactory.CreateClient();
+ client.BaseAddress = new Uri(Navigation.BaseUri);
+ var configure = await client.PostAsJsonAsync("api/setup/master-database", new { ConnectionString });
+ if (!configure.IsSuccessStatusCode)
+ {
+ Message = "The master database configuration could not be stored.";
+ return;
+ }
+
+ var initialize = await client.PostAsJsonAsync("api/setup/initialize", new { Email, Password });
+ Message = initialize.IsSuccessStatusCode ? "Setup completed. You can now sign in." : await initialize.Content.ReadAsStringAsync();
+ }
+ catch (Exception exception)
+ {
+ Message = exception.Message;
+ }
+ finally
+ {
+ Busy = false;
+ }
+ }
+}
From 6f2ff8ec4c263abe605bb7241d8808189cb20f9e Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:18:43 -0300
Subject: [PATCH 024/112] feat: add tenant setup razor page
---
.../Components/Pages/TenantSetup.razor | 46 +++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 src/server/RemoteTickets/Components/Pages/TenantSetup.razor
diff --git a/src/server/RemoteTickets/Components/Pages/TenantSetup.razor b/src/server/RemoteTickets/Components/Pages/TenantSetup.razor
new file mode 100644
index 0000000..b895486
--- /dev/null
+++ b/src/server/RemoteTickets/Components/Pages/TenantSetup.razor
@@ -0,0 +1,46 @@
+@page "/{tenantId}/setup"
+@using System.Net.Http.Json
+@inject IHttpClientFactory HttpClientFactory
+@inject NavigationManager Navigation
+
+Tenant setup
+
+Tenant setup
+Tenant @tenantId is not ready for normal operations. Run the database validation and complete setup.
+
+
+@if (!string.IsNullOrWhiteSpace(Message))
+{
+ @Message
+}
+
+@code {
+ [Parameter] public string tenantId { get; set; } = string.Empty;
+ private bool Busy;
+ private string Message = string.Empty;
+
+ private async Task CompleteAsync()
+ {
+ Busy = true;
+ try
+ {
+ var client = HttpClientFactory.CreateClient();
+ client.BaseAddress = new Uri(Navigation.BaseUri);
+ var response = await client.PostAsync($"api/v1/{Uri.EscapeDataString(tenantId)}/setup/complete", null);
+ if (response.IsSuccessStatusCode)
+ {
+ Navigation.NavigateTo($"/{tenantId}", true);
+ return;
+ }
+ Message = await response.Content.ReadAsStringAsync();
+ }
+ catch (Exception exception)
+ {
+ Message = exception.Message;
+ }
+ finally
+ {
+ Busy = false;
+ }
+ }
+}
From fa06ad7d6684ef3e73582a50c0644384701749ca Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:18:49 -0300
Subject: [PATCH 025/112] feat: scope home page to tenant route
---
.../RemoteTickets/Components/Pages/Home.razor | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/src/server/RemoteTickets/Components/Pages/Home.razor b/src/server/RemoteTickets/Components/Pages/Home.razor
index df05023..e2dda88 100644
--- a/src/server/RemoteTickets/Components/Pages/Home.razor
+++ b/src/server/RemoteTickets/Components/Pages/Home.razor
@@ -1,7 +1,6 @@
-@page "/"
-
-Home
-
-Hello, world!
-
-Welcome to your new app.
+@page "/{tenantId}"
+
+Home
+
+RemoteTickets
+Tenant: @tenantId
From 462b42f6f31bdf22ca27801195b3861948eb784f Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:18:53 -0300
Subject: [PATCH 026/112] feat: scope counter page to tenant route
---
.../Components/Pages/Counter.razor | 34 ++++++++-----------
1 file changed, 15 insertions(+), 19 deletions(-)
diff --git a/src/server/RemoteTickets/Components/Pages/Counter.razor b/src/server/RemoteTickets/Components/Pages/Counter.razor
index ba08d04..df64c8a 100644
--- a/src/server/RemoteTickets/Components/Pages/Counter.razor
+++ b/src/server/RemoteTickets/Components/Pages/Counter.razor
@@ -1,19 +1,15 @@
-@page "/counter"
-@rendermode InteractiveServer
-
-Counter
-
-Counter
-
-Current count: @currentCount
-
-
-
-@code {
- private int currentCount = 0;
-
- private void IncrementCount()
- {
- currentCount++;
- }
-}
+@page "/{tenantId}/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+Tenant: @tenantId
+Current count: @currentCount
+
+
+@code {
+ [Parameter] public string tenantId { get; set; } = string.Empty;
+ private int currentCount;
+ private void IncrementCount() => currentCount++;
+}
From b303d16be36951aa0011aeeb2215dd7e87fe086e Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:18:59 -0300
Subject: [PATCH 027/112] feat: scope weather page to tenant route
---
.../Components/Pages/Weather.razor | 114 ++++++++----------
1 file changed, 50 insertions(+), 64 deletions(-)
diff --git a/src/server/RemoteTickets/Components/Pages/Weather.razor b/src/server/RemoteTickets/Components/Pages/Weather.razor
index d00febb..2788543 100644
--- a/src/server/RemoteTickets/Components/Pages/Weather.razor
+++ b/src/server/RemoteTickets/Components/Pages/Weather.razor
@@ -1,64 +1,50 @@
-@page "/weather"
-@attribute [StreamRendering]
-
-Weather
-
-Weather
-
-This component demonstrates showing data.
-
-@if (forecasts == null)
-{
- Loading...
-}
-else
-{
-
-
-
- | Date |
- Temp. (C) |
- Temp. (F) |
- Summary |
-
-
-
- @foreach (var forecast in forecasts)
- {
-
- | @forecast.Date.ToShortDateString() |
- @forecast.TemperatureC |
- @forecast.TemperatureF |
- @forecast.Summary |
-
- }
-
-
-}
-
-@code {
- private WeatherForecast[]? forecasts;
-
- protected override async Task OnInitializedAsync()
- {
- // Simulate asynchronous loading to demonstrate streaming rendering
- await Task.Delay(500);
-
- var startDate = DateOnly.FromDateTime(DateTime.Now);
- var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
- forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
- {
- Date = startDate.AddDays(index),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = summaries[Random.Shared.Next(summaries.Length)]
- }).ToArray();
- }
-
- private class WeatherForecast
- {
- public DateOnly Date { get; set; }
- public int TemperatureC { get; set; }
- public string? Summary { get; set; }
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
- }
-}
+@page "/{tenantId}/weather"
+@attribute [StreamRendering]
+
+Weather
+
+Weather
+Tenant: @tenantId
+
+@if (forecasts == null)
+{
+ Loading...
+}
+else
+{
+
+ | Date | Temp. (C) | Temp. (F) | Summary |
+
+ @foreach (var forecast in forecasts)
+ {
+ | @forecast.Date.ToShortDateString() | @forecast.TemperatureC | @forecast.TemperatureF | @forecast.Summary |
+ }
+
+
+}
+
+@code {
+ [Parameter] public string tenantId { get; set; } = string.Empty;
+ private WeatherForecast[]? forecasts;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await Task.Delay(500);
+ var startDate = DateOnly.FromDateTime(DateTime.Now);
+ var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
+ forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
+ {
+ Date = startDate.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = summaries[Random.Shared.Next(summaries.Length)]
+ }).ToArray();
+ }
+
+ private sealed class WeatherForecast
+ {
+ public DateOnly Date { get; set; }
+ public int TemperatureC { get; set; }
+ public string? Summary { get; set; }
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+ }
+}
From 9ac97b1f778a53db898a603d37c43eb24f098b2d Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:19:08 -0300
Subject: [PATCH 028/112] fix: resolve master connection store through
dependency injection
---
.../InvisibleSPCompositionExtensions.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs b/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs
index b4d2572..91b9aa9 100644
--- a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs
+++ b/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs
@@ -12,16 +12,16 @@ public WebApplicationBuilder AddRemoteTickets()
{
var services = builder.Services;
var configuration = builder.Configuration;
-
services.AddRazorComponents().AddInteractiveServerComponents();
services.AddControllers();
+ services.AddHttpClient();
services.Configure(configuration.GetSection(JwtOptions.SectionName));
services.AddSingleton();
- services.AddDbContext(options =>
+ services.AddDbContext((sp, options) =>
{
- var setup = services.BuildServiceProvider().GetService();
- var connectionString = setup?.GetMasterConnectionString() ?? configuration.GetConnectionString("Identity") ?? configuration.GetConnectionString("Master");
+ var setup = sp.GetRequiredService();
+ var connectionString = setup.GetMasterConnectionString() ?? configuration.GetConnectionString("Identity") ?? configuration.GetConnectionString("Master");
if (string.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException("A master database connection string is required.");
options.UseSqlServer(connectionString);
});
From 674f2ffa92aa1b0eb950fc8f1feb0e86488ad5f2 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:19:23 -0300
Subject: [PATCH 029/112] fix: register tenant middleware namespaces
---
src/server/RemoteTickets.Composition/GlobalUsings.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/server/RemoteTickets.Composition/GlobalUsings.cs b/src/server/RemoteTickets.Composition/GlobalUsings.cs
index 4b9d898..2baca0e 100644
--- a/src/server/RemoteTickets.Composition/GlobalUsings.cs
+++ b/src/server/RemoteTickets.Composition/GlobalUsings.cs
@@ -5,12 +5,15 @@
global using System.Threading.Tasks;
global using FluentValidation;
global using RemoteTickets.Application.Common.Identity;
+global using RemoteTickets.Application.Common.Tenancy;
global using RemoteTickets.Application.Identity;
global using RemoteTickets.Application.Identity.Handlers;
global using RemoteTickets.Application.Identity.Validators;
global using RemoteTickets.Application.Pipeline.Validation;
global using RemoteTickets.Infrastructure.Persistence;
global using RemoteTickets.Infrastructure.Identity.Services;
+global using RemoteTickets.Infrastructure.Setup;
+global using RemoteTickets.Infrastructure.Tenancy;
global using Mediator.Net.MicrosoftDependencyInjection;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Builder;
From c71008d524a3a4ffefc00d1df34be6b15a6659f9 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:19:39 -0300
Subject: [PATCH 030/112] fix: harden tenant setup route detection
---
.../Tenancy/TenantAccessMiddleware.cs | 35 ++++++++++++-------
1 file changed, 23 insertions(+), 12 deletions(-)
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
index 52f336d..f58cf3d 100644
--- a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
@@ -5,10 +5,7 @@
namespace RemoteTickets.Infrastructure.Tenancy;
/// Enforces tenant route isolation and mandatory setup state for HTTP requests.
-public sealed class TenantAccessMiddleware(
- RequestDelegate next,
- ITenantManagementService tenants,
- IIdentityService identityService)
+public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, IIdentityService identityService)
{
/// Processes the current request and enforces tenant isolation when a tenant route is present.
/// The current HTTP context.
@@ -25,12 +22,7 @@ public async Task InvokeAsync(HttpContext context)
var systemStatus = await identityService.GetSetupStatusAsync(context.RequestAborted);
if (systemStatus.IsSetupRequired)
{
- if (IsSetupEndpoint(context))
- {
- await next(context);
- return;
- }
-
+ if (IsSystemSetupEndpoint(context)) { await next(context); return; }
await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
return;
}
@@ -42,6 +34,17 @@ public async Task InvokeAsync(HttpContext context)
}
var tenantId = value.ToString()!;
+ if (string.Equals(tenantId, "system", StringComparison.OrdinalIgnoreCase))
+ {
+ if (context.User.Identity?.IsAuthenticated != true || !context.User.IsInRole(TenantRoles.SysAdmin))
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+ await next(context);
+ return;
+ }
+
var tenant = await tenants.GetAsync(tenantId, context.RequestAborted);
if (tenant is null || !tenant.IsActive)
{
@@ -68,9 +71,17 @@ public async Task InvokeAsync(HttpContext context)
await next(context);
}
- private static bool IsSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup", StringComparison.OrdinalIgnoreCase) || context.Request.Path.Equals("/setup", StringComparison.OrdinalIgnoreCase);
+ private static bool IsSystemSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup") || string.Equals(context.Request.Path.Value, "/setup", StringComparison.OrdinalIgnoreCase);
- private static bool IsTenantSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments($"/{context.Request.RouteValues["tenantId"]}/setup", StringComparison.OrdinalIgnoreCase) || context.Request.Path.StartsWithSegments("/api/v1/", StringComparison.OrdinalIgnoreCase) && context.Request.Path.Value?.EndsWith("/setup", StringComparison.OrdinalIgnoreCase) == true;
+ private static bool IsTenantSetupEndpoint(HttpContext context)
+ {
+ var tenantId = context.Request.RouteValues.TryGetValue("tenantId", out var value) ? value?.ToString() : null;
+ if (string.IsNullOrWhiteSpace(tenantId)) return false;
+ var path = context.Request.Path.Value;
+ return string.Equals(path, $"/{tenantId}/setup", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(path, $"/api/v1/{tenantId}/setup", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(path, $"/api/v1/{tenantId}/setup/complete", StringComparison.OrdinalIgnoreCase);
+ }
private static async Task RejectOrRedirectAsync(HttpContext context, string location, int statusCode)
{
From 709d94a8eadf4d434c44c4e159b367e4c105c2a1 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:19:49 -0300
Subject: [PATCH 031/112] fix: enforce setup before anonymous endpoints
---
.../Tenancy/TenantAccessMiddleware.cs | 32 +++++++++++++------
1 file changed, 23 insertions(+), 9 deletions(-)
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
index f58cf3d..80d0b9b 100644
--- a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
@@ -13,12 +13,7 @@ public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManageme
public async Task InvokeAsync(HttpContext context)
{
var endpoint = context.GetEndpoint();
- if (endpoint?.Metadata.GetMetadata() is not null)
- {
- await next(context);
- return;
- }
-
+ var anonymous = endpoint?.Metadata.GetMetadata() is not null;
var systemStatus = await identityService.GetSetupStatusAsync(context.RequestAborted);
if (systemStatus.IsSetupRequired)
{
@@ -29,7 +24,8 @@ public async Task InvokeAsync(HttpContext context)
if (!context.Request.RouteValues.TryGetValue("tenantId", out var value) || string.IsNullOrWhiteSpace(value?.ToString()))
{
- await next(context);
+ if (anonymous) await next(context);
+ else await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
return;
}
@@ -52,7 +48,24 @@ public async Task InvokeAsync(HttpContext context)
return;
}
- if (context.User.Identity?.IsAuthenticated == true && !context.User.IsInRole(TenantRoles.SysAdmin))
+ if (anonymous)
+ {
+ if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context) && !IsTenantLoginEndpoint(context))
+ {
+ await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict);
+ return;
+ }
+ await next(context);
+ return;
+ }
+
+ if (context.User.Identity?.IsAuthenticated != true)
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ if (!context.User.IsInRole(TenantRoles.SysAdmin))
{
var assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value;
if (!string.Equals(assignedTenant, tenantId, StringComparison.OrdinalIgnoreCase))
@@ -73,6 +86,8 @@ public async Task InvokeAsync(HttpContext context)
private static bool IsSystemSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup") || string.Equals(context.Request.Path.Value, "/setup", StringComparison.OrdinalIgnoreCase);
+ private static bool IsTenantLoginEndpoint(HttpContext context) => string.Equals(context.Request.Path.Value, $"/api/v1/{context.Request.RouteValues["tenantId"]}/login", StringComparison.OrdinalIgnoreCase);
+
private static bool IsTenantSetupEndpoint(HttpContext context)
{
var tenantId = context.Request.RouteValues.TryGetValue("tenantId", out var value) ? value?.ToString() : null;
@@ -90,7 +105,6 @@ private static async Task RejectOrRedirectAsync(HttpContext context, string loca
context.Response.Redirect(location);
return;
}
-
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new { title = "Setup required", detail = "Complete the required setup before using this endpoint.", setup = location }, context.RequestAborted);
From 7dd76b6ff3b4b0bb8742d8769ecbe31cd5ca8f06 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:02 -0300
Subject: [PATCH 032/112] feat: initialize master schema during setup
---
src/server/RemoteTickets/Controllers/SetupController.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/server/RemoteTickets/Controllers/SetupController.cs b/src/server/RemoteTickets/Controllers/SetupController.cs
index 796a994..ce0c058 100644
--- a/src/server/RemoteTickets/Controllers/SetupController.cs
+++ b/src/server/RemoteTickets/Controllers/SetupController.cs
@@ -3,22 +3,24 @@ namespace RemoteTickets.Controllers;
/// Exposes anonymous endpoints used during first-time application setup.
/// The mediator used to dispatch setup requests.
/// The store used to persist the master database connection.
+/// The central database context used to initialize the master schema.
[ApiController]
[Route("api/setup")]
-public sealed class SetupController(IMediator mediator, ISetupConfigurationStore configurationStore) : ControllerBase
+public sealed class SetupController(IMediator mediator, ISetupConfigurationStore configurationStore, RemoteTicketsDbContext dbContext) : ControllerBase
{
/// Gets the current first-time setup status.
[HttpGet("status")]
[AllowAnonymous]
public Task GetStatus(CancellationToken cancellationToken) => mediator.RequestAsync(new GetSetupStatusQuery(), cancellationToken);
- /// Persists the master database connection string used to administer tenants.
+ /// Persists the master database connection string and initializes its schema.
[HttpPost("master-database")]
[AllowAnonymous]
public async Task ConfigureMasterDatabase(MasterDatabaseSetupRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ConnectionString)) return BadRequest(new { error = "A master database connection string is required." });
await configurationStore.SetMasterConnectionStringAsync(request.ConnectionString, cancellationToken);
+ await dbContext.Database.EnsureCreatedAsync(cancellationToken);
return Ok();
}
From 36900c85d41fec0075f9a66c61df506abc085352 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:07 -0300
Subject: [PATCH 033/112] fix: allow setup controller to initialize a selected
database
---
src/server/RemoteTickets/GlobalUsings.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/server/RemoteTickets/GlobalUsings.cs b/src/server/RemoteTickets/GlobalUsings.cs
index 9f833b3..4a01f27 100644
--- a/src/server/RemoteTickets/GlobalUsings.cs
+++ b/src/server/RemoteTickets/GlobalUsings.cs
@@ -8,8 +8,10 @@
global using RemoteTickets.Application.Identity.Requests;
global using RemoteTickets.Composition;
global using RemoteTickets.Components;
+global using RemoteTickets.Infrastructure.Persistence;
global using RemoteTickets.Infrastructure.Setup;
global using Mediator.Net;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.EntityFrameworkCore;
From ce280d8bcfe31341090209fcf02f20e66834d94f Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:13 -0300
Subject: [PATCH 034/112] fix: initialize the selected master database before
identity setup
---
src/server/RemoteTickets/Controllers/SetupController.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/server/RemoteTickets/Controllers/SetupController.cs b/src/server/RemoteTickets/Controllers/SetupController.cs
index ce0c058..18aaeab 100644
--- a/src/server/RemoteTickets/Controllers/SetupController.cs
+++ b/src/server/RemoteTickets/Controllers/SetupController.cs
@@ -3,10 +3,9 @@ namespace RemoteTickets.Controllers;
/// Exposes anonymous endpoints used during first-time application setup.
/// The mediator used to dispatch setup requests.
/// The store used to persist the master database connection.
-/// The central database context used to initialize the master schema.
[ApiController]
[Route("api/setup")]
-public sealed class SetupController(IMediator mediator, ISetupConfigurationStore configurationStore, RemoteTicketsDbContext dbContext) : ControllerBase
+public sealed class SetupController(IMediator mediator, ISetupConfigurationStore configurationStore) : ControllerBase
{
/// Gets the current first-time setup status.
[HttpGet("status")]
@@ -20,6 +19,8 @@ public async Task ConfigureMasterDatabase(MasterDatabaseSetupRequ
{
if (string.IsNullOrWhiteSpace(request.ConnectionString)) return BadRequest(new { error = "A master database connection string is required." });
await configurationStore.SetMasterConnectionStringAsync(request.ConnectionString, cancellationToken);
+ var options = new DbContextOptionsBuilder().UseSqlServer(request.ConnectionString).Options;
+ await using var dbContext = new RemoteTicketsDbContext(options);
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
return Ok();
}
From a1b49f3b547d4c5dfaddb191323e1dc8025ee7e5 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:25 -0300
Subject: [PATCH 035/112] fix: bind tenant scope to refresh tokens
---
.../Identity/Services/JwtTokenService.cs | 97 ++++---------------
1 file changed, 17 insertions(+), 80 deletions(-)
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
index ed649df..b50a229 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
@@ -11,24 +11,16 @@ public sealed class JwtTokenService(IOptions options, IRevokedTokenS
public TokenResponse CreateTokens(string userId, string email, IEnumerable roles, IEnumerable claims)
{
var now = DateTimeOffset.UtcNow;
- var access = CreateToken(userId, email, roles, claims, JwtTokenTypes.Access, now, _options.AccessTokenLifetime);
- var refresh = CreateToken(userId, email, roles, Array.Empty(), JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime);
-
- return new TokenResponse(
- "Bearer",
- new JwtSecurityTokenHandler().WriteToken(access),
- (int)_options.AccessTokenLifetime.TotalSeconds,
- new JwtSecurityTokenHandler().WriteToken(refresh));
+ var claimSet = claims.ToArray();
+ var access = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Access, now, _options.AccessTokenLifetime);
+ var refresh = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime);
+ return new TokenResponse("Bearer", new JwtSecurityTokenHandler().WriteToken(access), (int)_options.AccessTokenLifetime.TotalSeconds, new JwtSecurityTokenHandler().WriteToken(refresh));
}
///
public ClaimsPrincipal? ValidateToken(string token, bool validateLifetime = true)
{
- if (string.IsNullOrWhiteSpace(token))
- {
- return null;
- }
-
+ if (string.IsNullOrWhiteSpace(token)) return null;
var parameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
@@ -40,39 +32,21 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable
public string? GetTokenId(string token)
{
- try
- {
- return new JwtSecurityTokenHandler().ReadJwtToken(token).Id;
- }
- catch (ArgumentException)
- {
- return null;
- }
+ try { return new JwtSecurityTokenHandler().ReadJwtToken(token).Id; }
+ catch (ArgumentException) { return null; }
}
///
@@ -83,20 +57,10 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable roles,
- IEnumerable claims,
- string tokenType,
- DateTimeOffset issuedAt,
- TimeSpan lifetime)
+ private JwtSecurityToken CreateToken(string userId, string email, IEnumerable roles, IEnumerable claims, string tokenType, DateTimeOffset issuedAt, TimeSpan lifetime)
{
var tokenClaims = new List
{
@@ -105,29 +69,15 @@ private JwtSecurityToken CreateToken(
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new("token_type", tokenType)
};
-
tokenClaims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
tokenClaims.AddRange(claims);
-
- var credentials = new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256);
-
- return new JwtSecurityToken(
- issuer: _options.Issuer,
- audience: _options.Audience,
- claims: tokenClaims,
- notBefore: issuedAt.UtcDateTime,
- expires: issuedAt.Add(lifetime).UtcDateTime,
- signingCredentials: credentials);
+ return new JwtSecurityToken(issuer: _options.Issuer, audience: _options.Audience, claims: tokenClaims, notBefore: issuedAt.UtcDateTime, expires: issuedAt.Add(lifetime).UtcDateTime, signingCredentials: new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256));
}
private SymmetricSecurityKey CreateSecurityKey()
{
var bytes = Encoding.UTF8.GetBytes(_options.SecretKey);
- if (bytes.Length < 32)
- {
- throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits.");
- }
-
+ if (bytes.Length < 32) throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits.");
return new SymmetricSecurityKey(bytes);
}
}
@@ -136,30 +86,17 @@ private SymmetricSecurityKey CreateSecurityKey()
public sealed class RevokedTokenStore : IRevokedTokenStore
{
private readonly ConcurrentDictionary _tokens = new();
-
///
public bool IsRevoked(string tokenId)
{
- if (!_tokens.TryGetValue(tokenId, out var expiresAt))
- {
- return false;
- }
-
- if (expiresAt > DateTimeOffset.UtcNow)
- {
- return true;
- }
-
+ if (!_tokens.TryGetValue(tokenId, out var expiresAt)) return false;
+ if (expiresAt > DateTimeOffset.UtcNow) return true;
_tokens.TryRemove(tokenId, out _);
return false;
}
-
///
public void Revoke(string tokenId, DateTimeOffset expiresAt)
{
- if (expiresAt > DateTimeOffset.UtcNow)
- {
- _tokens[tokenId] = expiresAt;
- }
+ if (expiresAt > DateTimeOffset.UtcNow) _tokens[tokenId] = expiresAt;
}
}
From 6ca4736e0ae7da06f3039635ddf5d50d6576ee9f Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:33 -0300
Subject: [PATCH 036/112] fix: validate tenant binding on refresh tokens
---
.../Tenancy/TenantAccessMiddleware.cs | 65 +++++++------------
1 file changed, 25 insertions(+), 40 deletions(-)
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
index 80d0b9b..a6676df 100644
--- a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
+using RemoteTickets.Application.Common.Identity;
using RemoteTickets.Application.Common.Tenancy;
namespace RemoteTickets.Infrastructure.Tenancy;
/// Enforces tenant route isolation and mandatory setup state for HTTP requests.
-public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, IIdentityService identityService)
+public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, IIdentityService identityService, IJwtTokenService tokenService)
{
/// Processes the current request and enforces tenant isolation when a tenant route is present.
/// The current HTTP context.
@@ -24,69 +25,57 @@ public async Task InvokeAsync(HttpContext context)
if (!context.Request.RouteValues.TryGetValue("tenantId", out var value) || string.IsNullOrWhiteSpace(value?.ToString()))
{
- if (anonymous) await next(context);
- else await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
+ if (anonymous) await next(context); else await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
return;
}
var tenantId = value.ToString()!;
if (string.Equals(tenantId, "system", StringComparison.OrdinalIgnoreCase))
{
- if (context.User.Identity?.IsAuthenticated != true || !context.User.IsInRole(TenantRoles.SysAdmin))
- {
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
- return;
- }
+ if (context.User.Identity?.IsAuthenticated != true || !context.User.IsInRole(TenantRoles.SysAdmin)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; }
await next(context);
return;
}
var tenant = await tenants.GetAsync(tenantId, context.RequestAborted);
- if (tenant is null || !tenant.IsActive)
- {
- await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound);
- return;
- }
+ if (tenant is null || !tenant.IsActive) { await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound); return; }
if (anonymous)
{
- if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context) && !IsTenantLoginEndpoint(context))
- {
- await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict);
- return;
- }
+ if (IsTenantRefreshEndpoint(context) && !IsRefreshTokenForTenant(context, tenantId)) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
+ if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context) && !IsTenantLoginEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; }
await next(context);
return;
}
- if (context.User.Identity?.IsAuthenticated != true)
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- return;
- }
-
+ if (context.User.Identity?.IsAuthenticated != true) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
if (!context.User.IsInRole(TenantRoles.SysAdmin))
{
var assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value;
- if (!string.Equals(assignedTenant, tenantId, StringComparison.OrdinalIgnoreCase))
- {
- await RejectOrRedirectAsync(context, $"/{assignedTenant ?? tenantId}", StatusCodes.Status403Forbidden);
- return;
- }
+ if (!string.Equals(assignedTenant, tenantId, StringComparison.OrdinalIgnoreCase)) { await RejectOrRedirectAsync(context, $"/{assignedTenant ?? tenantId}", StatusCodes.Status403Forbidden); return; }
}
- if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context))
+ if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; }
+ await next(context);
+ }
+
+ private bool IsRefreshTokenForTenant(HttpContext context, string tenantId)
+ {
+ var token = context.Request.Headers.Authorization.ToString().Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(token))
{
- await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict);
- return;
+ var body = context.Request.Form;
+ token = body["refreshToken"].ToString();
}
-
- await next(context);
+ var principal = tokenService.ValidateToken(token);
+ if (principal is null || !string.Equals(principal.FindFirst("token_type")?.Value, JwtTokenTypes.Refresh, StringComparison.Ordinal)) return false;
+ if (principal.IsInRole(TenantRoles.SysAdmin)) return true;
+ return string.Equals(principal.FindFirst(TenantClaimTypes.TenantId)?.Value, tenantId, StringComparison.OrdinalIgnoreCase);
}
private static bool IsSystemSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup") || string.Equals(context.Request.Path.Value, "/setup", StringComparison.OrdinalIgnoreCase);
-
private static bool IsTenantLoginEndpoint(HttpContext context) => string.Equals(context.Request.Path.Value, $"/api/v1/{context.Request.RouteValues["tenantId"]}/login", StringComparison.OrdinalIgnoreCase);
+ private static bool IsTenantRefreshEndpoint(HttpContext context) => string.Equals(context.Request.Path.Value, $"/api/v1/{context.Request.RouteValues["tenantId"]}/refresh", StringComparison.OrdinalIgnoreCase);
private static bool IsTenantSetupEndpoint(HttpContext context)
{
@@ -100,11 +89,7 @@ private static bool IsTenantSetupEndpoint(HttpContext context)
private static async Task RejectOrRedirectAsync(HttpContext context, string location, int statusCode)
{
- if (HttpMethods.IsGet(context.Request.Method) && context.Request.Headers.Accept.Any(x => x.Contains("text/html", StringComparison.OrdinalIgnoreCase)))
- {
- context.Response.Redirect(location);
- return;
- }
+ if (HttpMethods.IsGet(context.Request.Method) && context.Request.Headers.Accept.Any(x => x.Contains("text/html", StringComparison.OrdinalIgnoreCase))) { context.Response.Redirect(location); return; }
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new { title = "Setup required", detail = "Complete the required setup before using this endpoint.", setup = location }, context.RequestAborted);
From 71e7d55892b22bdb5b0cc49bf349ecadf19c8e12 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:20:42 -0300
Subject: [PATCH 037/112] feat: add tenant-bound refresh identity operation
---
.../Identity/IIdentityService.cs | 83 +------------------
1 file changed, 2 insertions(+), 81 deletions(-)
diff --git a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
index 110a380..b263b25 100644
--- a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
+++ b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
@@ -4,114 +4,35 @@ namespace RemoteTickets.Application.Identity;
public interface IIdentityService
{
/// Registers a user and starts email confirmation.
- /// The user's email address.
- /// The user's password.
- /// The token used to cancel the operation.
- /// The registration result.
Task RegisterAsync(string email, string password, CancellationToken cancellationToken);
-
/// Authenticates a user and issues JWT tokens when all required factors are valid.
- /// The user's email address.
- /// The user's password.
- /// The authenticator code, when two-factor authentication is enabled.
- /// The recovery code, when a recovery code is used instead of an authenticator code.
- /// The token used to cancel the operation.
- /// The token pair, or when authentication fails.
Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken);
-
/// Authenticates a user against a tenant route and rejects users assigned to another tenant.
- /// The tenant identifier supplied by the route.
- /// The user's email address.
- /// The user's password.
- /// The authenticator code, when two-factor authentication is enabled.
- /// The recovery code, when a recovery code is used instead of an authenticator code.
- /// The token used to cancel the operation.
- /// The token pair, or when the credentials or tenant assignment are invalid.
Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken);
-
/// Creates the first administrator account for a tenant in the central identity store.
- /// The tenant identifier assigned to the user.
- /// The administrator email address.
- /// The administrator password.
- /// The token used to cancel the operation.
- /// The user creation result.
Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken);
-
/// Exchanges a valid refresh token for a new token pair.
- /// The refresh token to exchange.
- /// The token used to cancel the operation.
- /// The new token pair, or when the refresh token is invalid.
Task RefreshAsync(string refreshToken, CancellationToken cancellationToken);
-
+ /// Exchanges a refresh token while enforcing its tenant route binding.
+ Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken);
/// Revokes an access token until its natural expiration.
- /// The access token to revoke.
- /// The token used to cancel the operation.
- /// when revocation succeeds; otherwise .
Task RevokeAsync(string accessToken, CancellationToken cancellationToken);
-
/// Confirms a user's email address or a changed email address.
- /// The user's identifier.
- /// The confirmation token.
- /// The replacement email address when confirming an email change.
- /// The token used to cancel the operation.
- /// when confirmation succeeds; otherwise .
Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken);
-
/// Resends email confirmation when required.
- /// The user's email address.
- /// The token used to cancel the operation.
- /// The resend operation result.
Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken);
-
/// Starts password recovery for a password-bearing user.
- /// The user's email address.
- /// The token used to cancel the operation.
- /// A result that does not reveal whether an email is registered.
Task ForgotPasswordAsync(string email, CancellationToken cancellationToken);
-
/// Resets a user's password with a valid reset token.
- /// The user's email address.
- /// The password reset token.
- /// The replacement password.
- /// The token used to cancel the operation.
- /// The reset operation result.
Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken);
-
/// Gets basic identity information for a user.
- /// The user's identifier.
- /// The token used to cancel the operation.
- /// The identity information, or when the user does not exist.
Task GetInfoAsync(string userId, CancellationToken cancellationToken);
-
/// Updates identity information after validating the current password.
- /// The user's identifier.
- /// The replacement email address, or to keep the current address.
- /// The replacement password, or to keep the current password.
- /// The current password.
- /// The token used to cancel the operation.
- /// The update operation result.
Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken);
-
/// Configures authenticator-based two-factor authentication and recovery material.
- /// The user's identifier.
- /// Whether to enable or disable two-factor authentication.
- /// The authenticator code used to validate enabling two-factor authentication.
- /// Whether recovery codes should be regenerated.
- /// Whether the shared authenticator key should be regenerated.
- /// Whether the remembered machine state should be cleared.
- /// The token used to cancel the operation.
- /// The current configuration, or when the operation cannot be completed.
Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken);
-
/// Gets the current first-time setup status.
- /// The token used to cancel the operation.
- /// The setup status.
Task GetSetupStatusAsync(CancellationToken cancellationToken);
-
/// Creates the initial administrator account when setup has not yet completed.
- /// The administrator email address.
- /// The administrator password.
- /// The token used to cancel the operation.
- /// The setup operation result.
Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken);
}
From 43cc127902bf8db0c78f020e5000d3335dc5a1ca Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:21:18 -0300
Subject: [PATCH 038/112] feat: include tenant scope in refresh command
---
.../Identity/Requests/IdentityRequests.cs | 26 +++++++------------
1 file changed, 9 insertions(+), 17 deletions(-)
diff --git a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
index ed2aec7..33c1360 100644
--- a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
+++ b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
@@ -10,60 +10,52 @@ public sealed record RegisterCommand(string Email, string Password) : IRequest;
/// The user's password.
/// The authenticator code, when required.
/// The recovery code, when used instead of an authenticator code.
-/// The tenant route identifier. When supplied, the identity must belong to this tenant or be a system administrator.
+/// The tenant route identifier.
public sealed record LoginCommand(string Email, string Password, string? TwoFactorCode = null, string? TwoFactorRecoveryCode = null, string? TenantId = null) : IRequest;
/// Requests exchange of a refresh token.
/// The refresh token to exchange.
-public sealed record RefreshTokenCommand(string RefreshToken) : IRequest;
+/// The tenant route identifier used to validate token scope.
+public sealed record RefreshTokenCommand(string RefreshToken, string? TenantId = null) : IRequest;
/// Requests revocation of an access token.
/// The access token to revoke.
public sealed record RevokeTokenCommand(string AccessToken) : IRequest;
-
/// Requests confirmation of a user's email address.
/// The user identifier.
/// The confirmation token.
/// The replacement email address when confirming an email change.
public sealed record ConfirmEmailCommand(string UserId, string Code, string? ChangedEmail = null) : IRequest;
-
/// Requests that an email confirmation link be sent again.
/// The user's email address.
public sealed record ResendConfirmationEmailCommand(string Email) : IRequest;
-
/// Starts password recovery for an email address.
/// The email address to recover.
public sealed record ForgotPasswordCommand(string Email) : IRequest;
-
/// Requests a password reset.
/// The user's email address.
/// The password reset token.
/// The replacement password.
public sealed record ResetPasswordCommand(string Email, string ResetCode, string NewPassword) : IRequest;
-
/// Requests basic identity information for a user.
/// The user identifier.
public sealed record GetIdentityInfoQuery(string UserId) : IRequest;
-
/// Requests an update to identity information.
/// The user identifier.
-/// The replacement email address, or to keep the current address.
-/// The replacement password, or to keep the current password.
-/// The current password used to authorize the change.
+/// The replacement email address.
+/// The replacement password.
+/// The current password.
public sealed record UpdateIdentityInfoCommand(string UserId, string? NewEmail, string? NewPassword, string OldPassword) : IRequest;
-
/// Requests a change to authenticator-based two-factor authentication.
/// The user identifier.
/// Whether to enable or disable two-factor authentication.
-/// The authenticator code used when enabling two-factor authentication.
+/// The authenticator code.
/// Whether recovery codes should be regenerated.
-/// Whether the shared authenticator key should be regenerated.
-/// Whether the remembered machine state should be cleared.
+/// Whether the shared key should be regenerated.
+/// Whether remembered-machine state should be cleared.
public sealed record ConfigureTwoFactorCommand(string UserId, bool? Enable, string? TwoFactorCode, bool ResetRecoveryCodes, bool ResetSharedKey, bool ForgetMachine) : IRequest;
-
/// Requests the current first-time setup status.
public sealed record GetSetupStatusQuery : IRequest;
-
/// Requests creation of the initial administrator account.
/// The administrator email address.
/// The administrator password.
From 54029328aa09849909ced338b2bada29e6588531 Mon Sep 17 00:00:00 2001
From: Weslley Luiz <37993565+weslleymurdock@users.noreply.github.com>
Date: Sun, 16 Aug 2026 21:21:26 -0300
Subject: [PATCH 039/112] feat: enforce tenant binding in refresh handler
---
.../Identity/Handlers/IdentityHandlers.cs | 23 ++++---------------
1 file changed, 5 insertions(+), 18 deletions(-)
diff --git a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
index 846d330..346b15a 100644
--- a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
+++ b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
@@ -19,59 +19,46 @@ public sealed class IdentityHandlers(IIdentityService identityService) :
{
/// Executes the registration request.
public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.RegisterAsync(context.Message.Email, context.Message.Password, cancellationToken);
-
- /// Executes the login request and converts authentication failure to an application response.
+ /// Executes the login request.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
var message = context.Message;
- var result = message.TenantId is null
- ? await identityService.LoginAsync(message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken)
- : await identityService.LoginAsync(message.TenantId, message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken);
+ var result = message.TenantId is null ? await identityService.LoginAsync(message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken) : await identityService.LoginAsync(message.TenantId, message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken);
return result is null ? Response.Failure(["Invalid credentials."]) : Response.Success(result);
}
-
- /// Executes a refresh-token exchange.
+ /// Executes a tenant-bound refresh-token exchange.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
- var result = await identityService.RefreshAsync(context.Message.RefreshToken, cancellationToken);
+ var message = context.Message;
+ var result = message.TenantId is null ? await identityService.RefreshAsync(message.RefreshToken, cancellationToken) : await identityService.RefreshAsync(message.TenantId, message.RefreshToken, cancellationToken);
return result is null ? Response.Failure(["Invalid refresh token."]) : Response.Success(result);
}
-
/// Executes token revocation.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.RevokeAsync(context.Message.AccessToken, cancellationToken));
-
/// Executes email confirmation.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.ConfirmEmailAsync(context.Message.UserId, context.Message.Code, context.Message.ChangedEmail, cancellationToken));
-
/// Executes a confirmation-email resend request.
public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResendConfirmationEmailAsync(context.Message.Email, cancellationToken);
-
/// Executes a password-recovery request.
public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ForgotPasswordAsync(context.Message.Email, cancellationToken);
-
/// Executes a password reset.
public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResetPasswordAsync(context.Message.Email, context.Message.ResetCode, context.Message.NewPassword, cancellationToken);
-
/// Executes an identity information query.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
var result = await identityService.GetInfoAsync(context.Message.UserId, cancellationToken);
return result is null ? Response.Failure(["User not found."]) : Response.Success(result);
}
-
/// Executes an identity information update.
public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.UpdateInfoAsync(context.Message.UserId, context.Message.NewEmail, context.Message.NewPassword, context.Message.OldPassword, cancellationToken);
-
///