diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ae9f6b..6930bb0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,19 +20,19 @@ jobs:
dotnet-version: 9.0.x
- name: Restore dependencies
- run: dotnet restore src/Idmt.sln
+ run: dotnet restore src/Idmt.slnx
- name: Format check
- run: dotnet format src/Idmt.sln --verify-no-changes --verbosity diagnostic
+ run: dotnet format src/Idmt.slnx --verify-no-changes --verbosity diagnostic
- name: Build
- run: dotnet build src/Idmt.sln --no-restore --configuration Release /p:TreatWarningsAsErrors=true
+ run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true
- name: Run analyzers
- run: dotnet build src/Idmt.sln --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true
+ run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true
- name: Test
- run: dotnet test src/Idmt.sln --no-build --configuration Release --verbosity normal
+ run: dotnet test src/Idmt.slnx --no-build --configuration Release --verbosity normal
- name: Pack
run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output .
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index b5dbc19..eec3074 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -16,13 +16,13 @@ jobs:
dotnet-version: 9.0.x
- name: Restore dependencies
- run: dotnet restore src/Idmt.sln
+ run: dotnet restore src/Idmt.slnx
- name: Build
- run: dotnet build src/Idmt.sln --no-restore --configuration Release
+ run: dotnet build src/Idmt.slnx --no-restore --configuration Release
- name: Test
- run: dotnet test src/Idmt.sln --no-build --configuration Release
+ run: dotnet test src/Idmt.slnx --no-build --configuration Release
- name: Set Version
id: get_version
diff --git a/.gitignore b/.gitignore
index c6ddfad..3d09d56 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,5 @@ nunit-*.xml
*.db
*.db-shm
*.db-wal
+
+.claude/settings.local.json
diff --git a/src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs b/src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs
new file mode 100644
index 0000000..9783314
--- /dev/null
+++ b/src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs
@@ -0,0 +1,8 @@
+namespace Idmt.Plugin.Configuration;
+
+internal static class IdmtEndpointNames
+{
+ public const string ConfirmEmail = "ConfirmEmail";
+ public const string ConfirmEmailDirect = "ConfirmEmail-direct";
+ public const string PasswordReset = "ResetPassword";
+}
diff --git a/src/Idmt.Plugin/Configuration/IdmtOptions.cs b/src/Idmt.Plugin/Configuration/IdmtOptions.cs
index 34818e6..d50b85e 100644
--- a/src/Idmt.Plugin/Configuration/IdmtOptions.cs
+++ b/src/Idmt.Plugin/Configuration/IdmtOptions.cs
@@ -24,7 +24,7 @@ public class IdmtOptions
///
/// Identity configuration options
///
- public AuthOptions Identity { get; set; } = new();
+ public IdmtAuthOptions Identity { get; set; } = new();
///
/// Multi-tenant configuration options
@@ -35,6 +35,32 @@ public class IdmtOptions
/// Database configuration options
///
public DatabaseOptions Database { get; set; } = new();
+
+ ///
+ /// Rate limiting configuration options for auth endpoints
+ ///
+ public RateLimitingOptions RateLimiting { get; set; } = new();
+
+}
+
+///
+/// Controls how email confirmation links behave.
+///
+public enum EmailConfirmationMode
+{
+ ///
+ /// Email link points to GET /auth/confirm-email on the server, which confirms
+ /// the email directly (like Microsoft's reference implementation).
+ /// No client-side form needed for email confirmation.
+ ///
+ ServerConfirm,
+
+ ///
+ /// Email link points to ClientUrl/ConfirmEmailFormPath on the client app.
+ /// The client reads the token from the URL and calls POST /auth/confirm-email.
+ /// Default for SPA/mobile apps.
+ ///
+ ClientForm
}
///
@@ -42,8 +68,12 @@ public class IdmtOptions
///
public class ApplicationOptions
{
- public const string PasswordResetEndpointName = "ResetPassword";
- public const string ConfirmEmailEndpointName = "ConfirmEmail";
+ ///
+ /// URI prefix applied to all IDMT endpoint groups (/auth, /manage, /admin, /health).
+ /// Defaults to "/api/v1". Set to "" to restore the legacy unprefixed behavior.
+ /// Examples: "/api/v1", "/v2", "/api/v2", ""
+ ///
+ public string ApiPrefix { get; set; } = "/api/v1";
///
/// Base URL of the client application, if any (e.g. "https://myapp.com")
@@ -57,12 +87,19 @@ public class ApplicationOptions
public string ResetPasswordFormPath { get; set; } = "/reset-password";
public string ConfirmEmailFormPath { get; set; } = "/confirm-email";
+
+ ///
+ /// Controls how email confirmation links are generated.
+ /// ServerConfirm: link hits GET /auth/confirm-email which confirms directly.
+ /// ClientForm: link points to ClientUrl/ConfirmEmailFormPath for SPA handling.
+ ///
+ public EmailConfirmationMode EmailConfirmationMode { get; set; } = EmailConfirmationMode.ClientForm;
}
///
/// ASP.NET Core Identity configuration
///
-public class AuthOptions
+public class IdmtAuthOptions
{
public const string CookieOrBearerScheme = "CookieOrBearer";
@@ -75,7 +112,7 @@ public class AuthOptions
///
/// Password requirements
///
- public PasswordOptions Password { get; set; } = new();
+ public IdmtPasswordOptions Password { get; set; } = new();
///
/// User requirements
@@ -90,7 +127,7 @@ public class AuthOptions
///
/// Cookie configuration options
///
- public CookieOptions Cookie { get; set; } = new();
+ public IdmtCookieOptions Cookie { get; set; } = new();
///
/// Bearer token configuration options
@@ -106,13 +143,13 @@ public class AuthOptions
///
/// Password configuration options
///
-public class PasswordOptions
+public class IdmtPasswordOptions
{
public bool RequireDigit { get; set; } = true;
public bool RequireLowercase { get; set; } = true;
public bool RequireUppercase { get; set; } = true;
public bool RequireNonAlphanumeric { get; set; } = false;
- public int RequiredLength { get; set; } = 6;
+ public int RequiredLength { get; set; } = 8;
public int RequiredUniqueChars { get; set; } = 1;
}
@@ -130,19 +167,34 @@ public class UserOptions
///
public class SignInOptions
{
- public bool RequireConfirmedEmail { get; set; } = false;
+ public bool RequireConfirmedEmail { get; set; } = true;
public bool RequireConfirmedPhoneNumber { get; set; } = false;
}
///
/// Cookie configuration options
///
-public class CookieOptions
+public class IdmtCookieOptions
{
public string Name { get; set; } = ".Idmt.Application";
public bool HttpOnly { get; set; } = true;
- public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
- public Microsoft.AspNetCore.Http.SameSiteMode SameSite { get; set; } = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
+ public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
+
+ ///
+ /// Controls the SameSite attribute of the authentication cookie.
+ /// Defaults to , which means the
+ /// browser will never send the cookie on cross-site requests (neither top-level navigations
+ /// nor sub-resource loads). This is the strongest available CSRF protection at the cookie
+ /// layer and removes the need for anti-forgery tokens on state-mutating endpoints that rely
+ /// solely on cookie authentication.
+ ///
+ /// Change to only if your
+ /// application requires cookie preservation on top-level cross-site GET navigations (e.g.
+ /// OAuth / OIDC redirect flows), and compensate with explicit anti-forgery validation on
+ /// every state-mutating endpoint.
+ ///
+ public Microsoft.AspNetCore.Http.SameSiteMode SameSite { get; set; } = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
+
public TimeSpan ExpireTimeSpan { get; set; } = TimeSpan.FromDays(14);
public bool SlidingExpiration { get; set; } = true;
public bool IsRedirectEnabled { get; set; } = false;
@@ -191,7 +243,7 @@ public class MultiTenantOptions
///
public const string DefaultTenantIdentifier = "system-tenant";
- public string DefaultTenantDisplayName { get; set; } = "System Tenant";
+ public string DefaultTenantName { get; set; } = "System Tenant";
///
/// Tenant resolution strategy (header, subdomain, etc.)
@@ -204,18 +256,65 @@ public class MultiTenantOptions
public Dictionary StrategyOptions { get; set; } = [];
}
+///
+/// Controls how the IDMT database schema is initialized on startup.
+///
+public enum DatabaseInitializationMode
+{
+ ///
+ /// Use EF Core Migrations. Consumers must create and apply migrations themselves.
+ /// Recommended for production. Default.
+ ///
+ Migrate,
+
+ ///
+ /// Use EnsureCreated for quick setup. Not compatible with migrations.
+ /// Suitable for development, testing, and prototyping only.
+ ///
+ EnsureCreated,
+
+ ///
+ /// Skip automatic database initialization. Consumer manages the database schema externally.
+ ///
+ None
+}
+
///
/// Database configuration options
///
public class DatabaseOptions
{
///
- /// Connection string template with placeholder for tenant's properties
+ /// Controls how the database schema is initialized on startup.
+ /// runs EF Core migrations and is the
+ /// default for production use. is
+ /// suitable for development, testing, and prototyping where migrations are not used.
+ /// skips initialization entirely and leaves
+ /// schema management to the consumer.
+ ///
+ public DatabaseInitializationMode DatabaseInitialization { get; set; } = DatabaseInitializationMode.Migrate;
+}
+
+///
+/// Rate limiting configuration for IDMT auth endpoints.
+/// When enabled, a fixed-window limiter named "idmt-auth" is registered and applied
+/// to all authentication endpoints (login, token, forgot-password, etc.) to protect
+/// against brute-force and email-flooding attacks.
+///
+public class RateLimitingOptions
+{
+ ///
+ /// Enable built-in rate limiting for auth endpoints. Default: true.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Maximum number of requests allowed per window for auth endpoints. Default: 10.
///
- public string ConnectionStringTemplate { get; set; } = string.Empty;
+ public int PermitLimit { get; set; } = 10;
///
- /// Auto-migrate database on startup
+ /// Duration of the sliding window in seconds. Default: 60.
///
- public bool AutoMigrate { get; set; } = false;
+ public int WindowInSeconds { get; set; } = 60;
}
\ No newline at end of file
diff --git a/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs b/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs
new file mode 100644
index 0000000..6974467
--- /dev/null
+++ b/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs
@@ -0,0 +1,95 @@
+using Microsoft.Extensions.Options;
+
+namespace Idmt.Plugin.Configuration;
+
+///
+/// Validates at application startup so that
+/// misconfigured options are reported immediately rather than causing
+/// unexpected failures at runtime.
+///
+public sealed class IdmtOptionsValidator : IValidateOptions
+{
+ ///
+ public ValidateOptionsResult Validate(string? name, IdmtOptions options)
+ {
+ var failures = new List();
+
+ ValidateApplicationOptions(options.Application, failures);
+ ValidateMultiTenantOptions(options.MultiTenant, failures);
+
+ return failures.Count > 0
+ ? ValidateOptionsResult.Fail(failures)
+ : ValidateOptionsResult.Success;
+ }
+
+ private static void ValidateApplicationOptions(ApplicationOptions application, List failures)
+ {
+ // Rule 1: ApiPrefix must not be null or empty.
+ // An empty string is the documented opt-out for legacy unprefixed behavior,
+ // but null indicates the property was explicitly cleared and is misconfigured.
+ if (application.ApiPrefix is null)
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ApiPrefix)} must not be null. " +
+ "Use an empty string \"\" to disable the prefix or provide a value such as \"/api/v1\".");
+ }
+
+ // Rule 2: When EmailConfirmationMode is ClientForm, ClientUrl is required.
+ if (application.EmailConfirmationMode == EmailConfirmationMode.ClientForm &&
+ string.IsNullOrWhiteSpace(application.ClientUrl))
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ClientUrl)} must not be null or empty " +
+ $"when {nameof(ApplicationOptions.EmailConfirmationMode)} is {nameof(EmailConfirmationMode.ClientForm)}.");
+ }
+
+ // Rule 3: When ClientUrl is set, the client-side form paths must also be configured.
+ if (!string.IsNullOrWhiteSpace(application.ClientUrl))
+ {
+ if (string.IsNullOrWhiteSpace(application.ConfirmEmailFormPath))
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ConfirmEmailFormPath)} must not be null or empty " +
+ $"when {nameof(ApplicationOptions.ClientUrl)} is set.");
+ }
+
+ if (string.IsNullOrWhiteSpace(application.ResetPasswordFormPath))
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ResetPasswordFormPath)} must not be null or empty " +
+ $"when {nameof(ApplicationOptions.ClientUrl)} is set.");
+ }
+ }
+ }
+
+ private static void ValidateMultiTenantOptions(MultiTenantOptions multiTenant, List failures)
+ {
+ // Rule 4: The constant DefaultTenantIdentifier is a compile-time value and cannot be
+ // null or empty. Validate it defensively so that any future refactor to an instance
+ // property is caught immediately.
+ if (string.IsNullOrWhiteSpace(MultiTenantOptions.DefaultTenantIdentifier))
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.DefaultTenantIdentifier)} must not be null or empty.");
+ }
+
+ // Rule 5: DefaultTenantName is configurable and must not be null or empty.
+ if (string.IsNullOrWhiteSpace(multiTenant.DefaultTenantName))
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.DefaultTenantName)} must not be null or empty.");
+ }
+
+ // Rule 6: At least one tenant resolution strategy must be configured.
+ // The Strategies array controls which strategies (header, claim, route, basepath) are
+ // active. An empty array means no tenant can ever be resolved, which is always a
+ // misconfiguration. StrategyOptions holds per-strategy overrides and may be empty.
+ if (multiTenant.Strategies.Length == 0)
+ {
+ failures.Add(
+ $"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.Strategies)} must contain at least one entry. " +
+ $"Supported values: {IdmtMultiTenantStrategy.Header}, {IdmtMultiTenantStrategy.Claim}, " +
+ $"{IdmtMultiTenantStrategy.Route}, {IdmtMultiTenantStrategy.BasePath}.");
+ }
+ }
+}
diff --git a/src/Idmt.Plugin/Constants/AuditAction.cs b/src/Idmt.Plugin/Constants/AuditAction.cs
new file mode 100644
index 0000000..7180927
--- /dev/null
+++ b/src/Idmt.Plugin/Constants/AuditAction.cs
@@ -0,0 +1,8 @@
+namespace Idmt.Plugin.Constants;
+
+public enum AuditAction
+{
+ Created,
+ Modified,
+ Deleted
+}
diff --git a/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs b/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs
new file mode 100644
index 0000000..940b050
--- /dev/null
+++ b/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs
@@ -0,0 +1,6 @@
+namespace Idmt.Plugin.Constants;
+
+public static class IdmtClaimTypes
+{
+ public const string IsActive = "is_active";
+}
diff --git a/src/Idmt.Plugin/DT.cs b/src/Idmt.Plugin/DT.cs
deleted file mode 100644
index 0c5a7b5..0000000
--- a/src/Idmt.Plugin/DT.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Idmt.Plugin;
-
-///
-/// DateTime substiute to guarantee UTC timezone
-///
-public static class DT
-{
- public static DateTime UtcNow => DateTime.UtcNow;
- public static DateTimeOffset UtcNowOffset => DateTimeOffset.UtcNow;
-}
\ No newline at end of file
diff --git a/src/Idmt.Plugin/Errors/IdmtErrors.cs b/src/Idmt.Plugin/Errors/IdmtErrors.cs
new file mode 100644
index 0000000..48d667a
--- /dev/null
+++ b/src/Idmt.Plugin/Errors/IdmtErrors.cs
@@ -0,0 +1,153 @@
+using ErrorOr;
+
+namespace Idmt.Plugin.Errors;
+
+public static class IdmtErrors
+{
+ public static class Auth
+ {
+ public static Error Unauthorized => Error.Unauthorized(
+ code: "Auth.Unauthorized",
+ description: "Unauthorized");
+
+ public static Error Forbidden => Error.Forbidden(
+ code: "Auth.Forbidden",
+ description: "Forbidden");
+
+ public static Error UserDeactivated => Error.Forbidden(
+ code: "Auth.UserDeactivated",
+ description: "User is deactivated");
+
+ public static Error TwoFactorRequired => Error.Custom(
+ type: 42,
+ code: "Auth.TwoFactorRequired",
+ description: "Two-factor authentication is required");
+
+ public static Error InvalidCredentials => Error.Unauthorized(
+ code: "Auth.InvalidCredentials",
+ description: "Invalid credentials");
+
+ public static Error LockedOut => Error.Custom(
+ type: 43,
+ code: "Auth.LockedOut",
+ description: "Account is locked out due to too many failed attempts");
+ }
+
+ public static class Tenant
+ {
+ public static Error NotFound => Error.NotFound(
+ code: "Tenant.NotFound",
+ description: "Tenant not found");
+
+ public static Error Inactive => Error.Forbidden(
+ code: "Tenant.Inactive",
+ description: "Tenant is not active");
+
+ public static Error NotResolved => Error.Validation(
+ code: "Tenant.NotResolved",
+ description: "Tenant not resolved");
+
+ public static Error CannotDeleteDefault => Error.Forbidden(
+ code: "Tenant.CannotDeleteDefault",
+ description: "Cannot delete the default tenant");
+
+ public static Error CreationFailed => Error.Failure(
+ code: "Tenant.CreationFailed",
+ description: "Failed to create tenant");
+
+ public static Error UpdateFailed => Error.Failure(
+ code: "Tenant.UpdateFailed",
+ description: "Failed to update tenant");
+
+ public static Error DeletionFailed => Error.Failure(
+ code: "Tenant.DeletionFailed",
+ description: "Failed to delete tenant");
+
+ public static Error RoleSeedFailed => Error.Failure(
+ code: "Tenant.RoleSeedFailed",
+ description: "Failed to guarantee tenant roles");
+
+ public static Error AccessError => Error.Failure(
+ code: "Tenant.AccessError",
+ description: "An error occurred while managing tenant access");
+
+ public static Error AlreadyExists => Error.Conflict(
+ code: "Tenant.AlreadyExists",
+ description: "A tenant with this identifier already exists");
+
+ public static Error AccessNotFound => Error.NotFound(
+ code: "Tenant.AccessNotFound",
+ description: "No tenant access record found for this user");
+ }
+
+ public static class User
+ {
+ public static Error NotFound => Error.NotFound(
+ code: "User.NotFound",
+ description: "User not found");
+
+ public static Error CreationFailed => Error.Failure(
+ code: "User.CreationFailed",
+ description: "Failed to create user");
+
+ public static Error UpdateFailed => Error.Failure(
+ code: "User.UpdateFailed",
+ description: "Failed to update user");
+
+ public static Error RoleNotFound => Error.Validation(
+ code: "User.RoleNotFound",
+ description: "Role not found");
+
+ public static Error InsufficientPermissions => Error.Forbidden(
+ code: "User.InsufficientPermissions",
+ description: "Insufficient permissions");
+
+ public static Error NoRolesAssigned => Error.Validation(
+ code: "User.NoRolesAssigned",
+ description: "User has no roles assigned");
+
+ public static Error ClaimsNotFound => Error.Validation(
+ code: "User.ClaimsNotFound",
+ description: "User claims not found");
+
+ public static Error Inactive => Error.Forbidden(
+ code: "User.Inactive",
+ description: "User is not active");
+
+ public static Error DeletionFailed => Error.Failure(
+ code: "User.DeletionFailed",
+ description: "Failed to delete user");
+ }
+
+ public static class Token
+ {
+ public static Error Invalid => Error.Validation(
+ code: "Token.Invalid",
+ description: "Invalid token");
+
+ public static Error Revoked => Error.Unauthorized(
+ code: "Token.Revoked",
+ description: "Token has been revoked");
+ }
+
+ public static class Email
+ {
+ public static Error ConfirmationFailed => Error.Failure(
+ code: "Email.ConfirmationFailed",
+ description: "Unable to confirm email");
+ }
+
+ public static class Password
+ {
+ public static Error ResetFailed => Error.Failure(
+ code: "Password.ResetFailed",
+ description: "Unable to reset password");
+ }
+
+ public static class General
+ {
+ public static Error Unexpected => Error.Unexpected(
+ code: "General.Unexpected",
+ description: "An unexpected error occurred");
+ }
+}
diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs
index 4cd2a27..7a2ba63 100644
--- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs
+++ b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs
@@ -1,4 +1,3 @@
-using System.Reflection;
using Finbuckle.MultiTenant.AspNetCore.Extensions;
using Idmt.Plugin.Configuration;
using Idmt.Plugin.Features;
@@ -23,12 +22,15 @@ namespace Idmt.Plugin.Extensions;
public static class ApplicationBuilderExtensions
{
///
- /// Middleware pipeline for security before calling UseIdmt()
+ /// Adds IDMT middleware to the application pipeline, including security headers.
///
- /// The web application
- /// The web application
- public static IApplicationBuilder UseIdmtSecurity(this WebApplication app)
+ /// The application builder
+ /// The application builder
+ public static IApplicationBuilder UseIdmt(this IApplicationBuilder app)
{
+ var idmtOptions = app.ApplicationServices.GetRequiredService>().Value;
+
+ // Security headers
app.Use(async (context, next) =>
{
context.Response.Headers.XContentTypeOptions = "nosniff";
@@ -38,16 +40,13 @@ public static IApplicationBuilder UseIdmtSecurity(this WebApplication app)
await next();
});
- return app;
- }
+ // Rate limiting must be added before authentication so rejected requests
+ // never reach identity processing. Only registered when the feature is enabled.
+ if (idmtOptions.RateLimiting.Enabled)
+ {
+ app.UseRateLimiter();
+ }
- ///
- /// Adds IDMT middleware to the application pipeline
- ///
- /// The application builder
- /// The application builder
- public static IApplicationBuilder UseIdmt(this IApplicationBuilder app)
- {
// Add multi-tenant middleware - must come before authentication
app.UseMultiTenant();
@@ -66,31 +65,65 @@ public static IApplicationBuilder UseIdmt(this IApplicationBuilder app)
}
///
- /// Maps the IDMT endpoints. In case of route or basepath strategy, it's up
- /// to the caller to pass an adequate endpoint route builder.
+ /// Maps the IDMT endpoints under the configured API prefix. In case of route or
+ /// basepath strategy, it's up to the caller to pass an adequate endpoint route builder.
/// For example, if using the route strategy, the caller should pass the
/// endpoint route builder for the tenant, e.g. `/{__tenant__?}`.
+ ///
+ /// The API prefix (e.g. "/api/v1") is read from
+ /// and defaults to "/api/v1".
+ /// Set it to an empty string to restore the legacy unprefixed behavior.
///
/// The endpoint route builder
/// The endpoint route builder
+ ///
+ /// OpenAPI security scheme
+ ///
+ /// IDMT does not configure OpenAPI itself. To expose the Bearer token scheme in the
+ /// OpenAPI document generated by the host application, register an
+ /// IOpenApiDocumentTransformer before calling app.MapOpenApi().
+ /// See the XML documentation on AddIdmt in
+ /// for a complete code example.
+ ///
+ ///
public static IEndpointRouteBuilder MapIdmtEndpoints(this IEndpointRouteBuilder endpoints)
{
- endpoints.MapAuthEndpoints();
- endpoints.MapAuthManageEndpoints();
- endpoints.MapAdminEndpoints();
- endpoints.MapHealthChecks("/healthz").RequireAuthorization(AuthOptions.RequireSysUserPolicy);
+ var options = endpoints.ServiceProvider.GetRequiredService>();
+ var apiPrefix = options.Value.Application.ApiPrefix ?? string.Empty;
+
+ // Wrap all endpoint groups under the versioned prefix.
+ // When apiPrefix is "" the group is effectively a pass-through.
+ var prefixed = endpoints.MapGroup(apiPrefix);
+
+ prefixed.MapAuthEndpoints();
+ prefixed.MapAuthManageEndpoints();
+ prefixed.MapAdminEndpoints();
+ prefixed.MapHealthChecks("/healthz").RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy);
return endpoints;
}
///
- /// Ensures the database is created and optionally migrated.
- /// Only IdmtDbContext is used for migrations since it owns all table configurations.
- /// IdmtTenantStoreDbContext shares the same database but doesn't manage schema.
+ /// Initializes the IDMT database schema according to the configured
+ /// mode.
+ ///
+ /// Only is used here because it owns all table
+ /// configurations. shares the same
+ /// physical database and connection, so no separate initialization is needed for it.
+ ///
+ ///
+ /// - — applies pending EF Core migrations.
+ /// Recommended for production. Requires migrations to have been generated and added to
+ /// the consuming project.
+ /// - — creates the schema without
+ /// a migrations history table. Suitable for development, testing, and prototyping.
+ /// Not compatible with migrations.
+ /// - — skips all automatic initialization.
+ /// The consumer is responsible for managing the schema externally.
+ ///
///
/// The application builder
- /// Whether to automatically run migrations
- /// The application builder
- public static async Task EnsureIdmtDatabaseAsync(this IApplicationBuilder app, bool autoMigrate = false)
+ /// A task that completes when database initialization is done
+ public static async Task EnsureIdmtDatabaseAsync(this IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
var services = scope.ServiceProvider;
@@ -98,36 +131,20 @@ public static async Task EnsureIdmtDatabaseAsync(this IApplicationBuilder app, b
var options = services.GetRequiredService>();
var context = services.GetRequiredService();
- try
+ switch (options.Value.Database.DatabaseInitialization)
{
- var shouldMigrate = autoMigrate || options.Value.Database.AutoMigrate;
-
- if (shouldMigrate)
- {
- // Try to migrate, fall back to EnsureCreated if migrations not supported
- try
- {
- await context.Database.MigrateAsync();
- }
- catch (InvalidOperationException)
- {
- // Migrations not supported (e.g., in-memory database)
- await context.Database.EnsureCreatedAsync();
- }
- }
- else
- {
+ case DatabaseInitializationMode.Migrate:
+ await context.Database.MigrateAsync();
+ break;
+ case DatabaseInitializationMode.EnsureCreated:
await context.Database.EnsureCreatedAsync();
- }
-
- // NOTE: IdmtTenantStoreDbContext shares the same database/connection
- // No separate initialization needed - it accesses tables created above
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Database initialization failed: {ex.Message}");
- throw;
+ break;
+ case DatabaseInitializationMode.None:
+ break;
}
+
+ // NOTE: IdmtTenantStoreDbContext shares the same database/connection.
+ // No separate initialization needed — it accesses tables created above.
}
///
@@ -140,21 +157,13 @@ public static async Task SeedIdmtDataAsync(this IApplicatio
{
using var scope = app.ApplicationServices.CreateScope();
var services = scope.ServiceProvider;
- try
- {
- // Run default seeding
- await SeedDefaultDataAsync(services);
-
- // Run custom seeding if provided
- if (seedAction != null)
- {
- await seedAction(services);
- }
- }
- catch (Exception ex)
+ // Run default seeding
+ await SeedDefaultDataAsync(services);
+
+ // Run custom seeding if provided
+ if (seedAction != null)
{
- Console.Error.WriteLine($"Data seeding failed: {ex.Message}");
- throw;
+ await seedAction(services);
}
return app;
@@ -166,79 +175,20 @@ private static async Task SeedDefaultDataAsync(IServiceProvider services)
var createTenantHandler = services.GetRequiredService();
await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest(
MultiTenantOptions.DefaultTenantIdentifier,
- MultiTenantOptions.DefaultTenantIdentifier,
- options.Value.MultiTenant.DefaultTenantDisplayName));
+ options.Value.MultiTenant.DefaultTenantName));
}
private static void VerifyUserStoreSupportsEmail(IApplicationBuilder app)
{
- // Check if the service is registered without resolving it to avoid circular dependencies
- var serviceProvider = app.ApplicationServices;
-
- // Fallback: Use reflection to access service descriptors for older .NET versions
- var serviceDescriptors = GetServiceDescriptors(serviceProvider);
- var userStoreDescriptor = serviceDescriptors.FirstOrDefault(sd => sd.ServiceType == typeof(IUserStore))
- ?? throw new InvalidOperationException("No IUserStore is registered. Ensure Identity is configured before calling UseIdmt().");
-
- // Check if the implementation type implements IUserEmailStore
- var implementationType = userStoreDescriptor.ImplementationType;
- if (implementationType == null)
- {
- // If using a factory or instance, we can't verify without resolving
- // EntityFrameworkStores always implements IUserEmailStore, so we assume it's correct
- return;
- }
-
- // Verify the implementation type implements IUserEmailStore
- var emailStoreType = typeof(IUserEmailStore);
- if (!emailStoreType.IsAssignableFrom(implementationType))
- {
- throw new NotSupportedException($"Idmt.Plugin requires a user store that implements IUserEmailStore (email support). Found: {implementationType.FullName}");
- }
- }
-
- private static IEnumerable GetServiceDescriptors(IServiceProvider serviceProvider)
- {
- // Use reflection to access the internal CallSiteFactory which contains the service descriptors
- // This avoids resolving services and prevents circular dependency issues
- object? callSiteFactory = null;
-
- var field = serviceProvider.GetType().GetField("_serviceProvider", BindingFlags.NonPublic | BindingFlags.Instance)
- ?? serviceProvider.GetType().GetField("_callSiteFactory", BindingFlags.NonPublic | BindingFlags.Instance);
-
- if (field != null)
- {
- callSiteFactory = field.GetValue(serviceProvider);
- }
- else
- {
- var property = serviceProvider.GetType().GetProperty("CallSiteFactory", BindingFlags.NonPublic | BindingFlags.Instance);
- if (property != null)
- {
- callSiteFactory = property.GetValue(serviceProvider);
- }
- }
-
- if (callSiteFactory == null)
- {
- return [];
- }
-
- // Access the descriptors from the call site factory
- var descriptorsProperty = callSiteFactory.GetType().GetProperty("Descriptors", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
- if (descriptorsProperty != null)
+ using var scope = app.ApplicationServices.CreateScope();
+ var userStore = scope.ServiceProvider.GetService>();
+ if (userStore is null)
{
- var descriptors = descriptorsProperty.GetValue(callSiteFactory) as IEnumerable;
- return descriptors ?? Enumerable.Empty();
+ throw new InvalidOperationException("No IUserStore is registered. Ensure Identity is configured before calling UseIdmt().");
}
-
- var descriptorsField = callSiteFactory.GetType().GetField("_descriptors", BindingFlags.NonPublic | BindingFlags.Instance);
- if (descriptorsField != null)
+ if (userStore is not IUserEmailStore)
{
- var descriptors = descriptorsField.GetValue(callSiteFactory) as IEnumerable;
- return descriptors ?? Enumerable.Empty();
+ throw new NotSupportedException("Idmt.Plugin requires a user store that supports email (IUserEmailStore).");
}
-
- return [];
}
}
\ No newline at end of file
diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs
index b179378..a97c943 100644
--- a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs
@@ -13,11 +13,16 @@
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
+using System.Threading.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
+using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
namespace Idmt.Plugin.Extensions;
@@ -39,6 +44,41 @@ public static class ServiceCollectionExtensions
/// Optional action to configure the DbContext
/// Optional action to configure IDMT options
/// The service collection for method chaining
+ ///
+ /// OpenAPI / Swagger security scheme
+ ///
+ /// This library does not configure OpenAPI itself — the host application owns that
+ /// concern. To make the Bearer token visible in the generated OpenAPI document and
+ /// in Swagger UI, add a security scheme transformer in the host's service registration.
+ ///
+ ///
+ /// Example using the .NET 10 IOpenApiDocumentTransformer pattern:
+ ///
+ /// // In Program.cs / Startup.cs of the host application:
+ /// builder.Services.AddOpenApi(options =>
+ /// {
+ /// options.AddDocumentTransformer((document, context, cancellationToken) =>
+ /// {
+ /// document.Components ??= new OpenApiComponents();
+ /// document.Components.SecuritySchemes ??= new Dictionary<string, OpenApiSecurityScheme>();
+ /// document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
+ /// {
+ /// Type = SecuritySchemeType.Http,
+ /// Scheme = "bearer",
+ /// BearerFormat = "opaque",
+ /// Description = "Enter the bearer token obtained from POST /auth/login/token"
+ /// };
+ /// return Task.CompletedTask;
+ /// });
+ /// });
+ ///
+ ///
+ ///
+ /// Alternatively, implement IOpenApiDocumentTransformer in a dedicated class and
+ /// register it with options.AddDocumentTransformer<YourTransformer>() for
+ /// better testability and separation of concerns.
+ ///
+ ///
public static IServiceCollection AddIdmt(
this IServiceCollection services,
IConfiguration configuration,
@@ -53,29 +93,30 @@ public static IServiceCollection AddIdmt(
// 2. Configure Database Contexts
ConfigureDatabase(services, configureDb, idmtOptions);
- // 3. Configure MultiTenant
- // ConfigureMultiTenant(services, idmtOptions);
-
- // 4. Configure Identity
+ // 3. Configure Identity
ConfigureIdentity(services, idmtOptions);
- // 5. Configure Authentication
+ // 4. Configure Authentication
ConfigureAuthentication(services, idmtOptions, customizeAuthentication);
- // 6. Configure Authorization
+ // 5. Configure Authorization
ConfigureAuthorization(services, customizeAuthorization);
+ // 6. Configure MultiTenant
ConfigureMultiTenant(services, idmtOptions);
- // 6. Register Application Services
+ // 7. Register Application Services
RegisterApplicationServices(services);
- // 7. Register Feature Handlers
+ // 8. Register Feature Handlers
RegisterFeatures(services);
- // 8. Register Middleware
+ // 9. Register Middleware
RegisterMiddleware(services);
+ // 10. Configure Rate Limiting (auth endpoints only)
+ ConfigureRateLimiting(services, idmtOptions);
+
return services;
}
@@ -86,9 +127,11 @@ public static IServiceCollection AddIdmt(
this IServiceCollection services,
IConfiguration configuration,
Action? configureDb = null,
- Action? configureOptions = null)
+ Action? configureOptions = null,
+ CustomizeAuthentication? customizeAuthentication = null,
+ CustomizeAuthorization? customizeAuthorization = null)
{
- return services.AddIdmt(configuration, configureDb, configureOptions);
+ return services.AddIdmt(configuration, configureDb, configureOptions, customizeAuthentication, customizeAuthorization);
}
#region Private Configuration Methods
@@ -100,37 +143,45 @@ private static IdmtOptions ConfigureIdmtOptions(
{
var idmtSection = configuration.GetSection("Idmt");
- // If section doesn't exist and no custom configuration, return defaults
- if (!idmtSection.Exists() && configureOptions == null)
+ // Register the startup validator so misconfigured options surface immediately
+ // at application startup rather than on the first resolve of IOptions.
+ services.AddSingleton, IdmtOptionsValidator>();
+
+ // Build the canonical IdmtOptions instance exactly once.
+ // Previously, configureOptions was invoked twice: once here to build the local snapshot
+ // used during service registration, and again inside a services.Configure
+ // lambda when IOptions was first resolved. Registering via Options.Create
+ // wraps the fully-configured instance in a snapshot so both callers see the same object
+ // with no further binding or delegate invocations at resolve time.
+ var idmtOptions = idmtSection.Exists() ? new IdmtOptions() : IdmtOptions.Default;
+
+ if (idmtSection.Exists())
{
- var defaultOptions = IdmtOptions.Default;
- services.Configure(opts => { });
- return defaultOptions;
+ idmtSection.Bind(idmtOptions);
}
- var idmtOptions = new IdmtOptions();
- idmtSection.Bind(idmtOptions);
-
// Apply defaults for empty arrays (which means they weren't configured)
if (idmtOptions.MultiTenant.Strategies.Length == 0)
{
idmtOptions.MultiTenant.Strategies = IdmtOptions.Default.MultiTenant.Strategies;
}
+ // The caller's delegate runs exactly once against the canonical instance.
configureOptions?.Invoke(idmtOptions);
- services.Configure(opts =>
+ // Validate the fully-configured options eagerly. This runs during service registration
+ // (not deferred to first resolve) so misconfigurations surface immediately at startup.
+ var validator = new IdmtOptionsValidator();
+ var validationResult = validator.Validate(null, idmtOptions);
+ if (validationResult.Failed)
{
- idmtSection.Bind(opts);
-
- // Apply defaults for empty arrays (which means they weren't configured)
- if (opts.MultiTenant.Strategies.Length == 0)
- {
- opts.MultiTenant.Strategies = IdmtOptions.Default.MultiTenant.Strategies;
- }
+ throw new OptionsValidationException(nameof(IdmtOptions), typeof(IdmtOptions),
+ validationResult.Failures ?? [validationResult.FailureMessage]);
+ }
- configureOptions?.Invoke(opts);
- });
+ // Register the fully-configured snapshot. Every call to IOptions.Value
+ // returns this identical object — no re-binding or second delegate invocation occurs.
+ services.AddSingleton(Options.Create(idmtOptions));
return idmtOptions;
}
@@ -250,7 +301,7 @@ private static void ConfigureIdentity(IServiceCollection services, IdmtOptions i
})
.AddRoles()
.AddEntityFrameworkStores()
- .AddSignInManager()
+ .AddSignInManager()
.AddClaimsPrincipalFactory()
.AddDefaultTokenProviders();
}
@@ -263,8 +314,8 @@ private static void ConfigureAuthentication(
// Configure authentication with both cookie and bearer token support
var authenticationBuilder = services.AddAuthentication(options =>
{
- options.DefaultScheme = AuthOptions.CookieOrBearerScheme;
- options.DefaultChallengeScheme = AuthOptions.CookieOrBearerScheme;
+ options.DefaultScheme = IdmtAuthOptions.CookieOrBearerScheme;
+ options.DefaultChallengeScheme = IdmtAuthOptions.CookieOrBearerScheme;
});
// Cookie authentication
@@ -273,7 +324,16 @@ private static void ConfigureAuthentication(
{
options.Cookie.HttpOnly = idmtOptions.Identity.Cookie.HttpOnly;
options.Cookie.SecurePolicy = idmtOptions.Identity.Cookie.SecurePolicy;
- options.Cookie.SameSite = idmtOptions.Identity.Cookie.SameSite;
+
+ // SameSite=Strict is the primary CSRF mitigation for cookie-authenticated endpoints
+ // in this library. The browser will never attach the auth cookie to any cross-site
+ // request, eliminating the CSRF attack surface without requiring explicit anti-forgery
+ // tokens. SameSiteMode.None is explicitly blocked because it would remove all
+ // SameSite-based CSRF protection; the library falls back to Strict in that case.
+ options.Cookie.SameSite = idmtOptions.Identity.Cookie.SameSite == SameSiteMode.None
+ ? SameSiteMode.Strict
+ : idmtOptions.Identity.Cookie.SameSite;
+
options.ExpireTimeSpan = idmtOptions.Identity.Cookie.ExpireTimeSpan;
options.SlidingExpiration = idmtOptions.Identity.Cookie.SlidingExpiration;
@@ -325,7 +385,7 @@ private static void ConfigureAuthentication(
});
// PolicyScheme - automatically routes based on request
- authenticationBuilder.AddPolicyScheme(AuthOptions.CookieOrBearerScheme, "Cookie or Bearer", options =>
+ authenticationBuilder.AddPolicyScheme(IdmtAuthOptions.CookieOrBearerScheme, "Cookie or Bearer", options =>
{
options.ForwardDefaultSelector = context =>
{
@@ -348,34 +408,34 @@ private static void ConfigureAuthorization(IServiceCollection services, Customiz
{
// Configure authorization
var authorizationBuilder = services.AddAuthorizationBuilder()
- .SetDefaultPolicy(new AuthorizationPolicyBuilder(AuthOptions.CookieOrBearerScheme)
+ .SetDefaultPolicy(new AuthorizationPolicyBuilder(IdmtAuthOptions.CookieOrBearerScheme)
.RequireAuthenticatedUser()
.Build())
// Cookie-only policy (rare - for web-specific features)
- .AddPolicy(AuthOptions.CookieOnlyPolicy, policy => policy
+ .AddPolicy(IdmtAuthOptions.CookieOnlyPolicy, policy => policy
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(IdentityConstants.ApplicationScheme))
// Bearer-only policy (rare - for strict API endpoints)
- .AddPolicy(AuthOptions.BearerOnlyPolicy, policy => policy
+ .AddPolicy(IdmtAuthOptions.BearerOnlyPolicy, policy => policy
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(IdentityConstants.BearerScheme))
// Add system admin policy
- .AddPolicy(AuthOptions.RequireSysAdminPolicy, policy =>
+ .AddPolicy(IdmtAuthOptions.RequireSysAdminPolicy, policy =>
policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin)
- .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme))
+ .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme))
// Add system user policy
- .AddPolicy(AuthOptions.RequireSysUserPolicy, policy =>
+ .AddPolicy(IdmtAuthOptions.RequireSysUserPolicy, policy =>
policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin, IdmtDefaultRoleTypes.SysSupport)
- .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme))
+ .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme))
// Add tenant admin policy
- .AddPolicy(AuthOptions.RequireTenantManagerPolicy, policy =>
+ .AddPolicy(IdmtAuthOptions.RequireTenantManagerPolicy, policy =>
policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin, IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.TenantAdmin)
- .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme));
+ .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme));
customizeAuthorization?.Invoke(authorizationBuilder);
}
@@ -385,9 +445,24 @@ private static void RegisterApplicationServices(IServiceCollection services)
// Register scoped services for per-request context
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddHostedService();
services.AddScoped();
services.AddTransient, IdmtEmailSender>();
+ // Issue 23 fix: warn at startup if the stub email sender is still registered.
+ // If the consumer replaces IEmailSender with a real implementation before or
+ // after calling AddIdmt, ASP.NET Core DI resolves the last-registered descriptor, so the
+ // hosted service will resolve the custom sender and the warning will not be emitted.
+ services.AddHostedService();
+
+ // Register TimeProvider for testable time access
+ services.TryAddSingleton(TimeProvider.System);
+
+ // Register FluentValidation validators
+ services.AddValidatorsFromAssemblyContaining(ServiceLifetime.Scoped);
+
// Register HTTP context accessor for service access to HTTP context
services.AddHttpContextAccessor();
}
@@ -431,5 +506,27 @@ private static void RegisterMiddleware(IServiceCollection services)
services.AddScoped();
}
+ private static void ConfigureRateLimiting(IServiceCollection services, IdmtOptions idmtOptions)
+ {
+ if (!idmtOptions.RateLimiting.Enabled)
+ {
+ return;
+ }
+
+ services.AddRateLimiter(options =>
+ {
+ options.AddPolicy("idmt-auth", context =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = idmtOptions.RateLimiting.PermitLimit,
+ Window = TimeSpan.FromSeconds(idmtOptions.RateLimiting.WindowInSeconds),
+ QueueLimit = 0
+ }));
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ });
+ }
+
#endregion
}
\ No newline at end of file
diff --git a/src/Idmt.Plugin/Features/Admin/AdminModels.cs b/src/Idmt.Plugin/Features/Admin/AdminModels.cs
new file mode 100644
index 0000000..34e2941
--- /dev/null
+++ b/src/Idmt.Plugin/Features/Admin/AdminModels.cs
@@ -0,0 +1,26 @@
+namespace Idmt.Plugin.Features.Admin;
+
+public sealed record TenantInfoResponse(
+ string Id,
+ string Identifier,
+ string Name,
+ string Plan,
+ bool IsActive
+);
+
+///
+/// Generic paginated response envelope.
+///
+/// The item type contained in the page.
+/// The items on the current page.
+/// Total number of matching items across all pages.
+/// The 1-based current page number.
+/// The maximum number of items per page (capped at 100).
+/// True when additional pages exist after this one.
+public sealed record PaginatedResponse(
+ IReadOnlyList Items,
+ int TotalCount,
+ int Page,
+ int PageSize,
+ bool HasMore
+);
diff --git a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs
index be18c50..4b73a52 100644
--- a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs
+++ b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs
@@ -1,6 +1,10 @@
+using ErrorOr;
using Finbuckle.MultiTenant.Abstractions;
+using FluentValidation;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -18,31 +22,27 @@ public static class CreateTenant
{
public sealed record CreateTenantRequest(
string Identifier,
- string Name,
- string DisplayName
+ string Name
);
public sealed record CreateTenantResponse(
string Id,
string Identifier,
- string Name,
- string DisplayName
+ string Name
);
public interface ICreateTenantHandler
{
- Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default);
+ Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default);
}
internal sealed class CreateTenantHandler(
IMultiTenantStore tenantStore,
- IMultiTenantContextSetter tenantContextSetter,
- IMultiTenantContextAccessor tenantContextAccessor,
- IServiceProvider serviceProvider,
+ ITenantOperationService tenantOps,
IOptions options,
ILogger logger) : ICreateTenantHandler
{
- public async Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default)
{
IdmtTenantInfo resultTenant;
@@ -51,26 +51,25 @@ public async Task> HandleAsync(CreateTenantRequest
var existingTenant = await tenantStore.GetByIdentifierAsync(request.Identifier);
if (existingTenant is not null)
{
- if (!existingTenant.IsActive)
+ if (existingTenant.IsActive)
{
- existingTenant = existingTenant with { IsActive = true };
- if (!await tenantStore.UpdateAsync(existingTenant))
- {
- return Result.Failure("Failed to update tenant", StatusCodes.Status500InternalServerError);
- }
+ return IdmtErrors.Tenant.AlreadyExists;
+ }
+
+ existingTenant = existingTenant with { IsActive = true };
+ if (!await tenantStore.UpdateAsync(existingTenant))
+ {
+ return IdmtErrors.Tenant.UpdateFailed;
}
resultTenant = existingTenant;
}
else
{
- var tenant = new IdmtTenantInfo(request.Identifier, request.Name)
- {
- DisplayName = request.DisplayName
- };
+ var tenant = new IdmtTenantInfo(request.Identifier, request.Name);
if (!await tenantStore.AddAsync(tenant))
{
- return Result.Failure("Failed to create tenant", StatusCodes.Status400BadRequest);
+ return IdmtErrors.Tenant.CreationFailed;
}
resultTenant = tenant;
}
@@ -78,7 +77,7 @@ public async Task> HandleAsync(CreateTenantRequest
catch (Exception ex)
{
logger.LogError(ex, "Error creating tenant with identifier {Identifier}", request.Identifier);
- return Result.Failure($"Error creating tenant: {ex.Message}", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.General.Unexpected;
}
try
@@ -86,19 +85,19 @@ public async Task> HandleAsync(CreateTenantRequest
bool ok = await GuaranteeTenantRolesAsync(resultTenant);
if (!ok)
{
- return Result.Failure($"Failed to guarantee tenant roles.", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.Tenant.RoleSeedFailed;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error seeding roles for tenant {Identifier}", request.Identifier);
+ return IdmtErrors.Tenant.RoleSeedFailed;
}
- return Result.Success(new CreateTenantResponse(
+ return new CreateTenantResponse(
resultTenant.Id ?? string.Empty,
resultTenant.Identifier ?? string.Empty,
- resultTenant.Name ?? string.Empty,
- resultTenant.DisplayName ?? string.Empty), StatusCodes.Status200OK);
+ resultTenant.Name ?? string.Empty);
}
private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo)
@@ -109,81 +108,52 @@ private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo)
roles = [.. roles, .. options.Value.Identity.ExtraRoles];
}
- // Set tenant context before seeding roles to avoid NullReferenceException with multi-tenant filters
- var previousContext = tenantContextAccessor.MultiTenantContext;
- try
+ var result = await tenantOps.ExecuteInTenantScopeAsync(tenantInfo.Identifier!, async provider =>
{
- tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenantInfo);
-
- // Seed default roles
- using var scope = serviceProvider.CreateScope();
- var roleManager = scope.ServiceProvider.GetRequiredService>();
+ var roleManager = provider.GetRequiredService>();
foreach (var role in roles)
{
if (!await roleManager.RoleExistsAsync(role))
{
- var result = await roleManager.CreateAsync(new IdmtRole(role));
- if (!result.Succeeded)
+ var createResult = await roleManager.CreateAsync(new IdmtRole(role));
+ if (!createResult.Succeeded)
{
- return false;
+ return IdmtErrors.Tenant.RoleSeedFailed;
}
}
}
- }
- finally
- {
- // Restore previous context
- tenantContextSetter.MultiTenantContext = previousContext;
- }
+ return Result.Success;
+ }, requireActive: false);
- return true;
+ return !result.IsError;
}
}
- public static Dictionary? Validate(this CreateTenantRequest request)
- {
- var errors = new Dictionary();
-
- if (string.IsNullOrEmpty(request.Identifier))
- {
- errors["Identifier"] = ["Identifier is required"];
- }
- else if (!Validators.IsValidTenantIdentifier(request.Identifier))
- {
- errors["Identifier"] = ["Identifier can only contain lowercase alphanumeric characters, dashes, and underscores"];
- }
- if (string.IsNullOrEmpty(request.Name))
- {
- errors["Name"] = ["Name is required"];
- }
- if (string.IsNullOrEmpty(request.DisplayName))
- {
- errors["DisplayName"] = ["Display Name is required"];
- }
-
- return errors.Count > 0 ? errors : null;
- }
-
public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapPost("/tenants", static async Task, Created, ValidationProblem, BadRequest>> (
+ return endpoints.MapPost("/tenants", async Task, ValidationProblem, Conflict, BadRequest, ProblemHttpResult>> (
[FromBody] CreateTenantRequest request,
[FromServices] ICreateTenantHandler handler,
+ [FromServices] IValidator validator,
HttpContext context) =>
{
- if (request.Validate() is { } validationErrors)
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}
var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
- if (!response.IsSuccess)
+ if (response.IsError)
{
- return TypedResults.BadRequest();
+ return response.FirstError.Type switch
+ {
+ ErrorType.Conflict => TypedResults.Conflict(),
+ ErrorType.Validation => TypedResults.BadRequest(),
+ _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError),
+ };
}
- return TypedResults.Ok(response.Value);
+ return TypedResults.Created($"/admin/tenants/{response.Value.Identifier}", response.Value);
})
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
.WithSummary("Create Tenant")
.WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant");
}
-}
\ No newline at end of file
+}
diff --git a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs
index 080cc8b..bc9cdb6 100644
--- a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs
+++ b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs
@@ -1,5 +1,7 @@
+using ErrorOr;
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -14,38 +16,38 @@ public static class DeleteTenant
{
public interface IDeleteTenantHandler
{
- Task HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default);
+ Task> HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default);
}
internal sealed class DeleteTenantHandler(
IMultiTenantStore tenantStore,
ILogger logger) : IDeleteTenantHandler
{
- public async Task HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default)
{
try
{
if (string.Compare(tenantIdentifier, MultiTenantOptions.DefaultTenantIdentifier, StringComparison.OrdinalIgnoreCase) == 0)
{
- return Result.Failure("Cannot delete the default tenant", StatusCodes.Status403Forbidden);
+ return IdmtErrors.Tenant.CannotDeleteDefault;
}
var tenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
if (tenant is null)
{
- return Result.Failure("Tenant not found", StatusCodes.Status404NotFound);
+ return IdmtErrors.Tenant.NotFound;
}
tenant = tenant with { IsActive = false };
var updateResult = await tenantStore.UpdateAsync(tenant);
if (!updateResult)
{
- return Result.Failure("Failed to delete tenant", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.Tenant.DeletionFailed;
}
- return Result.Success();
+ return Result.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while deleting tenant with ID {TenantId}", tenantIdentifier);
- return Result.Failure($"An error occurred while deleting the tenant: {ex.Message}", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.General.Unexpected;
}
}
}
@@ -58,19 +60,19 @@ public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBui
CancellationToken cancellationToken = default) =>
{
var result = await handler.HandleAsync(tenantIdentifier, cancellationToken);
- if (!result.IsSuccess)
+ if (result.IsError)
{
- return result.StatusCode switch
+ return result.FirstError.Type switch
{
- StatusCodes.Status403Forbidden => TypedResults.Forbid(),
- StatusCodes.Status404NotFound => TypedResults.NotFound(),
+ ErrorType.Forbidden => TypedResults.Forbid(),
+ ErrorType.NotFound => TypedResults.NotFound(),
_ => TypedResults.InternalServerError(),
};
}
return TypedResults.NoContent();
})
- .RequireAuthorization(AuthOptions.RequireSysAdminPolicy)
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
.WithSummary("Delete tenant")
.WithDescription("Soft deletes a tenant by its identifier");
}
-}
\ No newline at end of file
+}
diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs
index b5f3f4e..3eda88d 100644
--- a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs
+++ b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs
@@ -1,65 +1,94 @@
-using Finbuckle.MultiTenant.Abstractions;
+using ErrorOr;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Persistence;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Idmt.Plugin.Features.Admin;
public static class GetAllTenants
{
+ private const int MaxPageSize = 100;
+
public interface IGetAllTenantsHandler
{
- Task> HandleAsync(CancellationToken cancellationToken = default);
+ Task>> HandleAsync(
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
}
internal sealed class GetAllTenantsHandler(
- IMultiTenantStore tenantStore,
+ IdmtDbContext dbContext,
ILogger logger) : IGetAllTenantsHandler
{
- public async Task> HandleAsync(CancellationToken cancellationToken = default)
+ public async Task>> HandleAsync(
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
{
try
{
- var tenants = await tenantStore.GetAllAsync();
- var res = tenants
- .Where(t => t is not null && !string.Equals(t.Identifier, MultiTenantOptions.DefaultTenantIdentifier, StringComparison.OrdinalIgnoreCase))
- .OrderBy(t => t.Name)
- .Select(t => new TenantInfoResponse(
- t!.Id ?? string.Empty,
- t.Identifier ?? string.Empty,
- t.Name ?? string.Empty,
- t.DisplayName ?? string.Empty,
- t.Plan ?? string.Empty,
- t.IsActive)).ToArray();
+ // Build a server-side query — no in-memory materialisation before pagination.
+ var query = dbContext.Set()
+ .Where(t => t.Identifier != MultiTenantOptions.DefaultTenantIdentifier)
+ .OrderBy(t => t.Name);
+
+ var totalCount = await query.CountAsync(cancellationToken);
+
+ var items = await query
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(t => new TenantInfoResponse(
+ t.Id ?? string.Empty,
+ t.Identifier ?? string.Empty,
+ t.Name ?? string.Empty,
+ t.Plan ?? string.Empty,
+ t.IsActive))
+ .ToListAsync(cancellationToken);
- return Result.Success(res);
+ var response = new PaginatedResponse(
+ items,
+ totalCount,
+ page,
+ pageSize,
+ HasMore: (page - 1) * pageSize + items.Count < totalCount);
+
+ return response;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while retrieving all tenants");
- return Result.Failure($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.General.Unexpected;
}
}
}
public static RouteHandlerBuilder MapGetAllTenantsEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapGet("/tenants", async Task, InternalServerError>> (
+ return endpoints.MapGet("/tenants", async Task>, InternalServerError>> (
IGetAllTenantsHandler handler,
- CancellationToken cancellationToken) =>
+ CancellationToken cancellationToken,
+ [Microsoft.AspNetCore.Mvc.FromQuery] int page = 1,
+ [Microsoft.AspNetCore.Mvc.FromQuery] int pageSize = 25) =>
{
- var result = await handler.HandleAsync(cancellationToken);
- if (!result.IsSuccess)
+ page = Math.Max(1, page);
+ pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
+
+ var result = await handler.HandleAsync(page, pageSize, cancellationToken);
+ if (result.IsError)
{
return TypedResults.InternalServerError();
}
- return TypedResults.Ok(result.Value!);
+ return TypedResults.Ok(result.Value);
})
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
- .WithSummary("Get tenants accessible by user");
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
+ .WithSummary("Get all tenants");
}
-}
\ No newline at end of file
+}
diff --git a/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs b/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs
deleted file mode 100644
index 5f00e7b..0000000
--- a/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using Finbuckle.MultiTenant.Abstractions;
-using Idmt.Plugin.Configuration;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.HttpResults;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Routing;
-
-namespace Idmt.Plugin.Features.Admin;
-
-public static class GetSystemInfo
-{
- public sealed record SystemInfoResponse
- {
- public string ApplicationName { get; set; } = string.Empty;
- public string Version { get; set; } = string.Empty;
- public string Environment { get; set; } = string.Empty;
- public TenantInfo? CurrentTenant { get; set; }
- public DateTime ServerTime { get; set; }
- public List Features { get; set; } = [];
- }
-
- public sealed record TenantInfo
- {
- public string? Id { get; set; }
- public string? Name { get; set; }
- public string? Identifier { get; set; }
- }
-
- public static RouteHandlerBuilder MapGetSystemInfoEndpoint(this IEndpointRouteBuilder endpoints)
- {
- return endpoints.MapGet("/info", Task> (
- [FromServices] IMultiTenantContextAccessor tenantAccessor) =>
- {
- var currentTenant = tenantAccessor.MultiTenantContext?.TenantInfo;
-
- var systemInfo = new SystemInfoResponse
- {
- ApplicationName = "IDMT Sample API",
- Version = "1.0.0",
- Environment = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production",
- CurrentTenant = currentTenant != null ? new TenantInfo
- {
- Id = currentTenant.Id,
- Name = currentTenant.Name,
- Identifier = currentTenant.Identifier
- } : null,
- ServerTime = DT.UtcNow,
- Features =
- [
- "Multi-Tenant Support",
- "Vertical Slice Architecture",
- "Minimal APIs",
- "OpenAPI/Swagger Documentation"
- ]
- };
-
- return Task.FromResult(TypedResults.Ok(systemInfo));
- })
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
- .WithSummary("Detailed system information");
- }
-}
diff --git a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs
index e13a4d8..fc4a3fb 100644
--- a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs
+++ b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs
@@ -1,5 +1,6 @@
-using Finbuckle.MultiTenant.Abstractions;
+using ErrorOr;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Persistence;
using Microsoft.AspNetCore.Builder;
@@ -11,72 +12,94 @@
namespace Idmt.Plugin.Features.Admin;
-public sealed record TenantInfoResponse(
- string Id,
- string Identifier,
- string Name,
- string DisplayName,
- string Plan,
- bool IsActive
-);
-
public static class GetUserTenants
{
+ private const int MaxPageSize = 100;
+
public interface IGetUserTenantsHandler
{
- Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default);
+ Task>> HandleAsync(
+ Guid userId,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
}
internal sealed class GetUserTenantsHandler(
IdmtDbContext dbContext,
- IMultiTenantStore tenantStore,
+ TimeProvider timeProvider,
ILogger logger) : IGetUserTenantsHandler
{
- public async Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default)
+ public async Task>> HandleAsync(
+ Guid userId,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
{
try
{
- var tenantIds = await dbContext.TenantAccess
- .Where(ta => ta.UserId == userId && ta.IsActive)
- .Select(ta => ta.TenantId)
- .ToArrayAsync(cancellationToken);
+ var now = timeProvider.GetUtcNow();
- var tenantTasks = tenantIds.Select(tenantStore.GetAsync);
- var tenants = await Task.WhenAll(tenantTasks);
+ // Base query: join TenantAccess with TenantInfo, ordered deterministically.
+ var query = dbContext.TenantAccess
+ .Where(ta => ta.UserId == userId && ta.IsActive &&
+ (ta.ExpiresAt == null || ta.ExpiresAt > now))
+ .Join(dbContext.Set(),
+ ta => ta.TenantId,
+ ti => ti.Id,
+ (ta, ti) => ti)
+ .OrderBy(ti => ti.Name);
- var res = tenants.Where(t => t != null).Select(t => new TenantInfoResponse(
- t!.Id ?? string.Empty,
- t.Identifier ?? string.Empty,
- t.Name ?? string.Empty,
- t.DisplayName ?? string.Empty,
- t.Plan ?? string.Empty,
- t.IsActive)).ToArray();
+ var totalCount = await query.CountAsync(cancellationToken);
- return Result.Success(res);
+ var items = await query
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(ti => new TenantInfoResponse(
+ ti.Id ?? string.Empty,
+ ti.Identifier ?? string.Empty,
+ ti.Name ?? string.Empty,
+ ti.Plan ?? string.Empty,
+ ti.IsActive))
+ .ToListAsync(cancellationToken);
+
+ var response = new PaginatedResponse(
+ items,
+ totalCount,
+ page,
+ pageSize,
+ HasMore: (page - 1) * pageSize + items.Count < totalCount);
+
+ return response;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while retrieving tenants for user {UserId}", userId);
- return Result.Failure($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.General.Unexpected;
}
}
}
public static RouteHandlerBuilder MapGetUserTenantsEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapGet("/users/{userId:guid}/tenants", async Task, InternalServerError>> (
+ return endpoints.MapGet("/users/{userId:guid}/tenants", async Task>, InternalServerError>> (
Guid userId,
IGetUserTenantsHandler handler,
- CancellationToken cancellationToken) =>
+ CancellationToken cancellationToken,
+ [Microsoft.AspNetCore.Mvc.FromQuery] int page = 1,
+ [Microsoft.AspNetCore.Mvc.FromQuery] int pageSize = 25) =>
{
- var result = await handler.HandleAsync(userId, cancellationToken);
- if (!result.IsSuccess)
+ page = Math.Max(1, page);
+ pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
+
+ var result = await handler.HandleAsync(userId, page, pageSize, cancellationToken);
+ if (result.IsError)
{
return TypedResults.InternalServerError();
}
- return TypedResults.Ok(result.Value!);
+ return TypedResults.Ok(result.Value);
})
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
.WithSummary("Get tenants accessible by user");
}
}
diff --git a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs
index 2b1b71c..7c6e507 100644
--- a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs
+++ b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs
@@ -1,7 +1,10 @@
+using ErrorOr;
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Persistence;
+using Idmt.Plugin.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
@@ -16,105 +19,101 @@ namespace Idmt.Plugin.Features.Admin;
public static class GrantTenantAccess
{
- public sealed record GrantAccessRequest(DateTime? ExpiresAt);
+ public sealed record GrantAccessRequest(DateTimeOffset? ExpiresAt);
public interface IGrantTenantAccessHandler
{
- Task HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default);
+ Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default);
}
+ // Issue 19 fix: inject IdmtDbContext, UserManager, and IMultiTenantStore
+ // as constructor parameters rather than resolving them from a manually-created IServiceProvider
+ // scope. The manual scope bypassed the request lifetime, causing audit-log fields that depend on
+ // ICurrentUserService (resolved through the request scope) to be null.
internal sealed class GrantTenantAccessHandler(
- IServiceProvider serviceProvider,
+ IdmtDbContext dbContext,
+ UserManager userManager,
+ IMultiTenantStore tenantStore,
+ ITenantOperationService tenantOps,
+ TimeProvider timeProvider,
ILogger logger
) : IGrantTenantAccessHandler
{
- public async Task HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(Guid userId, string tenantIdentifier, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default)
{
- IdmtUser? user = null;
- IdmtTenantInfo? targetTenant = null;
- IList userRoles = [];
- using (var scope = serviceProvider.CreateScope())
+ if (expiresAt.HasValue && expiresAt.Value <= timeProvider.GetUtcNow())
{
- var sp = scope.ServiceProvider;
+ return Error.Validation("ExpiresAt", "Expiration date must be in the future");
+ }
- try
+ IdmtUser? user;
+ IdmtTenantInfo? targetTenant;
+ IList userRoles;
+
+ try
+ {
+ user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
+ if (user is null)
{
- var dbContext = sp.GetRequiredService();
- var userManager = sp.GetRequiredService>();
- var tenantStore = sp.GetRequiredService>();
+ return IdmtErrors.User.NotFound;
+ }
- user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
- if (user is null)
- {
- return Result.Failure("User not found", StatusCodes.Status404NotFound);
- }
+ targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
+ if (targetTenant is null)
+ {
+ return IdmtErrors.Tenant.NotFound;
+ }
- targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
- if (targetTenant is null)
- {
- return Result.Failure("Tenant not found", StatusCodes.Status404NotFound);
- }
+ if (!targetTenant.IsActive)
+ {
+ return IdmtErrors.Tenant.Inactive;
+ }
- userRoles = await userManager.GetRolesAsync(user);
- if (userRoles.Count == 0)
- {
- logger.LogWarning("User {UserId} has no roles assigned; cannot grant tenant access.", userId);
- return Result.Failure("User has no roles assigned", StatusCodes.Status400BadRequest);
- }
+ userRoles = await userManager.GetRolesAsync(user);
+ if (userRoles.Count == 0)
+ {
+ logger.LogWarning("User {UserId} has no roles assigned; cannot grant tenant access.", userId);
+ return IdmtErrors.User.NoRolesAssigned;
+ }
- var tenantAccess = await dbContext.TenantAccess
- .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken);
- if (tenantAccess is not null)
- {
- tenantAccess.IsActive = true;
- tenantAccess.ExpiresAt = expiresAt;
- dbContext.TenantAccess.Update(tenantAccess);
- }
- else
- {
- tenantAccess = new TenantAccess
- {
- UserId = userId,
- TenantId = targetTenant.Id,
- IsActive = true,
- ExpiresAt = expiresAt
- };
- dbContext.TenantAccess.Add(tenantAccess);
- }
- await dbContext.SaveChangesAsync(cancellationToken);
+ var tenantAccess = await dbContext.TenantAccess
+ .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken);
+ if (tenantAccess is not null)
+ {
+ tenantAccess.IsActive = true;
+ tenantAccess.ExpiresAt = expiresAt;
+ dbContext.TenantAccess.Update(tenantAccess);
}
- catch (Exception ex)
+ else
{
- logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier);
- return Result.Failure("An error occurred while granting tenant access", StatusCodes.Status500InternalServerError);
+ tenantAccess = new TenantAccess
+ {
+ UserId = userId,
+ TenantId = targetTenant.Id,
+ IsActive = true,
+ ExpiresAt = expiresAt
+ };
+ dbContext.TenantAccess.Add(tenantAccess);
}
}
-
- using (var scope = serviceProvider.CreateScope())
+ catch (Exception ex)
{
- var sp = scope.ServiceProvider;
-
- var tenantStore = sp.GetRequiredService>();
- var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
- if (tenantInfo is null || !tenantInfo.IsActive)
- {
- return Result.Failure("Tenant not found or inactive", StatusCodes.Status404NotFound);
- }
- // Set Tenant Context BEFORE resolving DbContext/Managers
- var tenantContextSetter = sp.GetRequiredService();
- var tenantContext = new MultiTenantContext(tenantInfo);
- tenantContextSetter.MultiTenantContext = tenantContext;
+ logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier);
+ return IdmtErrors.Tenant.AccessError;
+ }
+ // Execute tenant-scope operation BEFORE persisting TenantAccess to prevent orphaned records
+ var tenantResult = await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp =>
+ {
try
{
- var targetUserManager = sp.GetRequiredService>();
+ var targetUserManager = tsp.GetRequiredService>();
var targetUser = await targetUserManager.Users
.FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken);
if (targetUser is null)
{
- // Create new user record for the target tenant
targetUser = new IdmtUser
{
UserName = user.UserName,
@@ -132,8 +131,18 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date
IsActive = true
};
- await targetUserManager.CreateAsync(targetUser);
- await targetUserManager.AddToRolesAsync(targetUser, userRoles);
+ var createResult = await targetUserManager.CreateAsync(targetUser);
+ if (!createResult.Succeeded)
+ {
+ logger.LogError("Failed to create user in target tenant: {Errors}", string.Join(", ", createResult.Errors.Select(e => e.Description)));
+ return IdmtErrors.Tenant.AccessError;
+ }
+ var roleResult = await targetUserManager.AddToRolesAsync(targetUser, userRoles);
+ if (!roleResult.Succeeded)
+ {
+ logger.LogError("Failed to assign roles in target tenant: {Errors}", string.Join(", ", roleResult.Errors.Select(e => e.Description)));
+ return IdmtErrors.Tenant.AccessError;
+ }
}
else
{
@@ -141,14 +150,67 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date
await targetUserManager.UpdateAsync(targetUser);
}
- return Result.Success();
+ return Result.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "Error granting tenant access to user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier);
- return Result.Failure("An error occurred while granting tenant access", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.Tenant.AccessError;
}
+ });
+
+ if (tenantResult.IsError)
+ {
+ return tenantResult;
+ }
+
+ // Tenant-scope operation succeeded — now persist the TenantAccess record
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
}
+ catch (Exception ex)
+ {
+ logger.LogError(ex,
+ "Failed to save TenantAccess record for user {UserId} in tenant {TenantIdentifier}. " +
+ "Executing compensating action to deactivate user in target tenant.",
+ userId, tenantIdentifier);
+
+ // Compensating action: deactivate the user in the target tenant
+ await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp =>
+ {
+ try
+ {
+ var compensationUserManager = tsp.GetRequiredService>();
+ var orphanedUser = await compensationUserManager.Users
+ .FirstOrDefaultAsync(u => u.Email == user!.Email && u.UserName == user.UserName, cancellationToken);
+
+ if (orphanedUser is not null)
+ {
+ orphanedUser.IsActive = false;
+ await compensationUserManager.UpdateAsync(orphanedUser);
+ logger.LogWarning(
+ "Compensating action completed: deactivated user {Email} in tenant {TenantIdentifier} " +
+ "after TenantAccess save failure.",
+ user!.Email, tenantIdentifier);
+ }
+
+ return Result.Success;
+ }
+ catch (Exception compensationEx)
+ {
+ logger.LogCritical(compensationEx,
+ "CRITICAL: Compensating action failed for user {UserId} in tenant {TenantIdentifier}. " +
+ "Manual intervention required: user exists in target tenant without a TenantAccess record.",
+ userId, tenantIdentifier);
+ return IdmtErrors.Tenant.AccessError;
+ }
+ });
+
+ return IdmtErrors.Tenant.AccessError;
+ }
+
+ return Result.Success;
}
}
@@ -162,18 +224,18 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(userId, tenantIdentifier, request.ExpiresAt, cancellationToken);
- if (!result.IsSuccess)
+ if (result.IsError)
{
- return result.StatusCode switch
+ return result.FirstError.Type switch
{
- StatusCodes.Status400BadRequest => TypedResults.BadRequest(),
- StatusCodes.Status404NotFound => TypedResults.NotFound(),
- _ => TypedResults.InternalServerError()
+ ErrorType.Validation => TypedResults.BadRequest(),
+ ErrorType.NotFound => TypedResults.NotFound(),
+ _ => TypedResults.InternalServerError(),
};
}
return TypedResults.Ok();
})
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
.WithSummary("Grant user access to a tenant");
}
}
diff --git a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs
index 7d04ed2..839f04e 100644
--- a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs
+++ b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs
@@ -1,7 +1,10 @@
+using ErrorOr;
using Finbuckle.MultiTenant.Abstractions;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Persistence;
+using Idmt.Plugin.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
@@ -17,113 +20,96 @@ public static class RevokeTenantAccess
{
public interface IRevokeTenantAccessHandler
{
- Task HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default);
+ Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default);
}
+ // Fix: inject IdmtDbContext, UserManager, and IMultiTenantStore
+ // as constructor parameters rather than resolving them from a manually-created IServiceProvider
+ // scope. The manual scope bypassed the request lifetime, causing audit-log fields that depend on
+ // ICurrentUserService (resolved through the request scope) to be null.
internal sealed class RevokeTenantAccessHandler(
- IServiceProvider serviceProvider,
+ IdmtDbContext dbContext,
+ IMultiTenantStore tenantStore,
+ ITenantOperationService tenantOps,
ILogger logger) : IRevokeTenantAccessHandler
{
- public async Task HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default)
{
IdmtUser? user;
- using (var scope = serviceProvider.CreateScope())
- {
- var sp = scope.ServiceProvider;
- try
+ try
+ {
+ user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
+ if (user is null)
{
- var dbContext = sp.GetRequiredService();
- var userManager = sp.GetRequiredService>();
- var tenantStore = sp.GetRequiredService>();
-
- user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
- if (user is null)
- {
- return Result.Failure("User not found", StatusCodes.Status404NotFound);
- }
-
- var targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
- if (targetTenant is null)
- {
- return Result.Failure("Tenant not found", StatusCodes.Status404NotFound);
- }
-
- var tenantAccess = await dbContext.TenantAccess
- .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken);
- if (tenantAccess is not null)
- {
- tenantAccess.IsActive = false;
- dbContext.TenantAccess.Update(tenantAccess);
- }
- await dbContext.SaveChangesAsync(cancellationToken);
+ return IdmtErrors.User.NotFound;
}
- catch (Exception ex)
+
+ var targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
+ if (targetTenant is null)
{
- logger.LogError(ex, "Error revoking tenant access for user {UserId} and tenant {TenantIdentifier}", userId, tenantIdentifier);
- return Result.Failure("An error occurred while revoking tenant access", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.Tenant.NotFound;
}
- }
-
- using (var scope = serviceProvider.CreateScope())
- {
- var sp = scope.ServiceProvider;
- var tenantStore = sp.GetRequiredService>();
- var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier);
- if (tenantInfo is null)
+ var tenantAccess = await dbContext.TenantAccess
+ .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken);
+ if (tenantAccess is null)
{
- return Result.Failure("Tenant not found", StatusCodes.Status404NotFound);
+ return IdmtErrors.Tenant.AccessNotFound;
}
- // Set Tenant Context BEFORE resolving DbContext/Managers
- var tenantContextSetter = sp.GetRequiredService();
- var tenantContext = new MultiTenantContext(tenantInfo);
- tenantContextSetter.MultiTenantContext = tenantContext;
- var userManager = sp.GetRequiredService>();
+ tenantAccess.IsActive = false;
+ dbContext.TenantAccess.Update(tenantAccess);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error revoking tenant access for user {UserId} and tenant {TenantIdentifier}", userId, tenantIdentifier);
+ return IdmtErrors.Tenant.AccessError;
+ }
+
+ return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async sp =>
+ {
+ var tenantUserManager = sp.GetRequiredService>();
try
{
- var targetUser = await userManager.Users.FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken);
- if (targetUser is null)
- {
- return Result.Success();
- }
- else
+ var targetUser = await tenantUserManager.Users.FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken);
+ if (targetUser is not null)
{
targetUser.IsActive = false;
- await userManager.UpdateAsync(targetUser);
+ await tenantUserManager.UpdateAsync(targetUser);
}
- return Result.Success();
+ return Result.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "Error deactivating user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier);
- return Result.Failure("An error occurred while deactivating user", StatusCodes.Status500InternalServerError);
+ return IdmtErrors.Tenant.AccessError;
}
- }
+ }, requireActive: false);
}
}
public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantId}", async Task> (
+ return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantIdentifier}", async Task> (
Guid userId,
- string tenantId,
+ string tenantIdentifier,
IRevokeTenantAccessHandler handler,
CancellationToken cancellationToken) =>
{
- var result = await handler.HandleAsync(userId, tenantId, cancellationToken);
- if (!result.IsSuccess)
+ var result = await handler.HandleAsync(userId, tenantIdentifier, cancellationToken);
+ if (result.IsError)
{
- return result.StatusCode switch
+ return result.FirstError.Type switch
{
- StatusCodes.Status404NotFound => TypedResults.NotFound(),
+ ErrorType.NotFound => TypedResults.NotFound(),
_ => TypedResults.InternalServerError(),
};
}
- return TypedResults.Ok();
+ return TypedResults.NoContent();
})
- .RequireAuthorization(AuthOptions.RequireSysUserPolicy)
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
.WithSummary("Revoke user access from a tenant");
}
}
diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/src/Idmt.Plugin/Features/AdminEndpoints.cs
index 1286941..dfe369d 100644
--- a/src/Idmt.Plugin/Features/AdminEndpoints.cs
+++ b/src/Idmt.Plugin/Features/AdminEndpoints.cs
@@ -1,3 +1,4 @@
+using Idmt.Plugin.Configuration;
using Idmt.Plugin.Features.Admin;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -10,6 +11,7 @@ public static class AdminEndpoints
public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints)
{
var admin = endpoints.MapGroup("/admin")
+ .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy)
.WithTags("Admin");
admin.MapCreateTenantEndpoint();
@@ -17,7 +19,6 @@ public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints)
admin.MapGetUserTenantsEndpoint();
admin.MapGrantTenantAccessEndpoint();
admin.MapRevokeTenantAccessEndpoint();
- admin.MapGetSystemInfoEndpoint();
admin.MapGetAllTenantsEndpoint();
}
}
\ No newline at end of file
diff --git a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs
index 52998ca..8faec74 100644
--- a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs
+++ b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs
@@ -1,6 +1,9 @@
-using Finbuckle.MultiTenant.Abstractions;
+using ErrorOr;
+using FluentValidation;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -19,110 +22,117 @@ public sealed record ConfirmEmailRequest(string TenantIdentifier, string Email,
public interface IConfirmEmailHandler
{
- Task HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default);
+ Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default);
}
- internal sealed class ConfirmEmailHandler(IServiceProvider serviceProvider, ILogger logger) : IConfirmEmailHandler
+ internal sealed class ConfirmEmailHandler(ITenantOperationService tenantOps, ILogger logger) : IConfirmEmailHandler
{
- public async Task HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default)
{
- using var scope = serviceProvider.CreateScope();
- var provider = scope.ServiceProvider;
-
- var tenantStore = provider.GetRequiredService>();
- var tenantInfo = await tenantStore.GetByIdentifierAsync(request.TenantIdentifier);
- if (tenantInfo is null || !tenantInfo.IsActive)
+ return await tenantOps.ExecuteInTenantScopeAsync(request.TenantIdentifier, async provider =>
{
- return Result.Failure("Invalid tenant", StatusCodes.Status400BadRequest);
- }
- // Set Tenant Context BEFORE resolving DbContext/Managers
- var tenantContextSetter = provider.GetRequiredService();
- var tenantContext = new MultiTenantContext(tenantInfo);
- tenantContextSetter.MultiTenantContext = tenantContext;
-
- var userManager = provider.GetRequiredService>();
- try
- {
- var user = await userManager.FindByEmailAsync(request.Email);
- if (user == null)
+ var userManager = provider.GetRequiredService>();
+ try
{
- // Avoid revealing that the email does not exist
- return Result.Failure("User not found", StatusCodes.Status400BadRequest);
- }
+ var user = await userManager.FindByEmailAsync(request.Email);
+ if (user == null)
+ {
+ return IdmtErrors.Email.ConfirmationFailed;
+ }
+
+ var result = await userManager.ConfirmEmailAsync(user, request.Token!);
- // Reset password using the token
- var result = await userManager.ConfirmEmailAsync(user, request.Token!);
+ if (!result.Succeeded)
+ {
+ return IdmtErrors.Email.ConfirmationFailed;
+ }
- if (!result.Succeeded)
+ return Result.Success;
+ }
+ catch (Exception ex)
{
- var errors = string.Join("\n", result.Errors.Select(e => e.Description));
- return Result.Failure(errors, StatusCodes.Status400BadRequest);
+ logger.LogError(ex, "Error confirming email for {Email} in tenant {TenantIdentifier}", PiiMasker.MaskEmail(request.Email), request.TenantIdentifier);
+ return IdmtErrors.General.Unexpected;
}
-
- return Result.Success();
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Error confirming email for {Email} in tenant {TenantIdentifier}", request.Email, request.TenantIdentifier);
- return Result.Failure(ex.Message, StatusCodes.Status500InternalServerError);
- }
+ });
}
}
- public static Dictionary? Validate(this ConfirmEmailRequest request)
+ public static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBuilder endpoints)
{
- var errors = new Dictionary();
-
- if (string.IsNullOrEmpty(request.TenantIdentifier))
- {
- errors["TenantIdentifier"] = ["Tenant identifier is required"];
- }
- if (string.IsNullOrEmpty(request.Email))
- {
- errors["Email"] = ["Email is required"];
- }
- if (!Validators.IsValidEmail(request.Email))
- {
- errors["Email"] = ["Invalid email address"];
- }
- if (string.IsNullOrEmpty(request.Token))
+ return endpoints.MapPost("/confirm-email", async Task> (
+ [FromBody] ConfirmEmailRequest request,
+ [FromServices] IConfirmEmailHandler handler,
+ [FromServices] IValidator validator,
+ HttpContext context) =>
{
- errors["Token"] = ["Token is required"];
- }
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
+ {
+ return TypedResults.ValidationProblem(validationErrors);
+ }
- return errors.Count == 0 ? null : errors;
+ // Decode Base64URL-encoded token
+ string decodedToken;
+ try
+ {
+ decodedToken = Base64Service.DecodeBase64UrlToken(request.Token);
+ }
+ catch (FormatException)
+ {
+ return TypedResults.BadRequest();
+ }
+
+ var decodedRequest = request with { Token = decodedToken };
+ var result = await handler.HandleAsync(decodedRequest, cancellationToken: context.RequestAborted);
+
+ if (result.IsError)
+ {
+ return result.FirstError.Type switch
+ {
+ ErrorType.NotFound => TypedResults.BadRequest(),
+ ErrorType.Failure => TypedResults.BadRequest(),
+ _ => TypedResults.InternalServerError(),
+ };
+ }
+ return TypedResults.Ok();
+ })
+ .WithName(IdmtEndpointNames.ConfirmEmail)
+ .WithSummary("Confirm email")
+ .WithDescription("Confirm user email address");
}
- public static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBuilder endpoints)
+ public static RouteHandlerBuilder MapConfirmEmailDirectEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapGet("/confirmEmail", async Task> (
+ return endpoints.MapGet("/confirm-email", async Task> (
[FromQuery] string tenantIdentifier,
[FromQuery] string email,
[FromQuery] string token,
[FromServices] IConfirmEmailHandler handler,
- IServiceProvider sp,
HttpContext context) =>
{
- var request = new ConfirmEmailRequest(tenantIdentifier, email, token);
- if (request.Validate() is { } validationErrors)
+ // Decode Base64URL-encoded token
+ string decodedToken;
+ try
{
- return TypedResults.ValidationProblem(validationErrors);
+ decodedToken = Base64Service.DecodeBase64UrlToken(token);
+ }
+ catch (FormatException)
+ {
+ return TypedResults.BadRequest();
}
+ var request = new ConfirmEmailRequest(tenantIdentifier, email, decodedToken);
var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
- if (!result.IsSuccess)
+ if (result.IsError)
{
- return result.StatusCode switch
- {
- StatusCodes.Status400BadRequest => TypedResults.BadRequest(),
- _ => TypedResults.InternalServerError(),
- };
+ return TypedResults.BadRequest();
}
+
return TypedResults.Ok();
})
- .WithName(ApplicationOptions.ConfirmEmailEndpointName)
- .WithSummary("Confirm email")
- .WithDescription("Confirm user email address");
+ .WithName(IdmtEndpointNames.ConfirmEmailDirect)
+ .WithSummary("Confirm email directly")
+ .WithDescription("Directly confirms user email address via GET link from email");
}
}
diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs
index b9f05c4..d5af0ff 100644
--- a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs
+++ b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs
@@ -1,7 +1,11 @@
+using ErrorOr;
+using FluentValidation;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
@@ -14,12 +18,11 @@ public static class ForgotPassword
{
public sealed record ForgotPasswordRequest(string Email);
- public sealed record ForgotPasswordResponse(string? ResetToken = null, string? ResetUrl = null);
+ public sealed record ForgotPasswordResponse;
public interface IForgotPasswordHandler
{
- Task> HandleAsync(
- bool useApiLinks,
+ Task> HandleAsync(
ForgotPasswordRequest request,
CancellationToken cancellationToken = default);
}
@@ -27,10 +30,10 @@ Task> HandleAsync(
internal sealed class ForgotPasswordHandler(
UserManager userManager,
IEmailSender emailSender,
- IIdmtLinkGenerator linkGenerator) : IForgotPasswordHandler
+ IIdmtLinkGenerator linkGenerator,
+ ILogger logger) : IForgotPasswordHandler
{
- public async Task> HandleAsync(
- bool useApiLinks,
+ public async Task> HandleAsync(
ForgotPasswordRequest request,
CancellationToken cancellationToken = default)
{
@@ -40,57 +43,45 @@ public async Task> HandleAsync(
if (user == null || !user.IsActive)
{
// Don't reveal whether user exists or not for security
- return Result.Success(new ForgotPasswordResponse(null, null), StatusCodes.Status200OK);
+ return new ForgotPasswordResponse();
}
// Generate password reset token
var token = await userManager.GeneratePasswordResetTokenAsync(user);
- // Generate password reset link
- var resetUrl = useApiLinks
- ? linkGenerator.GeneratePasswordResetApiLink(user.Email!, token)
- : linkGenerator.GeneratePasswordResetFormLink(user.Email!, token);
+ // Generate password reset link (always client form URL)
+ var resetUrl = linkGenerator.GeneratePasswordResetLink(user.Email!, token);
// Send email with reset code
await emailSender.SendPasswordResetCodeAsync(user, request.Email, resetUrl);
- return Result.Success(new ForgotPasswordResponse(token, resetUrl), StatusCodes.Status200OK);
+ return new ForgotPasswordResponse();
}
catch (Exception ex)
{
- return Result.Failure(ex.Message, StatusCodes.Status500InternalServerError);
+ logger.LogError(ex, "An error occurred during forgot password for {Email}",
+ request.Email.Length > 3 ? string.Concat(request.Email.AsSpan(0, 3), "***") : "***");
+ return IdmtErrors.General.Unexpected;
}
}
}
- public static Dictionary? Validate(this ForgotPasswordRequest request)
- {
- var errors = new Dictionary();
-
- if (!Validators.IsValidEmail(request.Email))
- {
- errors["Email"] = ["Invalid email address."];
- }
-
- return errors.Count == 0 ? null : errors;
- }
-
public static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapPost("/forgotPassword", async Task> (
- [FromQuery] bool useApiLinks,
+ return endpoints.MapPost("/forgot-password", async Task> (
[FromBody] ForgotPasswordRequest request,
[FromServices] IForgotPasswordHandler handler,
+ [FromServices] IValidator validator,
HttpContext context) =>
{
- if (request.Validate() is { } validationErrors)
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}
- var result = await handler.HandleAsync(useApiLinks, request, cancellationToken: context.RequestAborted);
- if (!result.IsSuccess)
+ var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
+ if (result.IsError)
{
- return TypedResults.StatusCode(result.StatusCode);
+ return TypedResults.StatusCode(StatusCodes.Status500InternalServerError);
}
return TypedResults.Ok();
})
diff --git a/src/Idmt.Plugin/Features/Auth/Login.cs b/src/Idmt.Plugin/Features/Auth/Login.cs
index e73c362..a519796 100644
--- a/src/Idmt.Plugin/Features/Auth/Login.cs
+++ b/src/Idmt.Plugin/Features/Auth/Login.cs
@@ -1,6 +1,10 @@
+using ErrorOr;
using Finbuckle.MultiTenant.Abstractions;
+using FluentValidation;
using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.BearerToken;
@@ -42,14 +46,14 @@ public sealed record AccessTokenResponse
public interface ILoginHandler
{
- Task> HandleAsync(
+ Task> HandleAsync(
LoginRequest loginRequest,
CancellationToken cancellationToken = default);
}
public interface ITokenLoginHandler
{
- Task> HandleAsync(
+ Task> HandleAsync(
LoginRequest request,
CancellationToken cancellationToken = default);
}
@@ -58,9 +62,11 @@ internal sealed class LoginHandler(
UserManager userManager,
SignInManager signInManager,
IMultiTenantContextAccessor multiTenantContextAccessor,
+ IOptions idmtOptions,
+ TimeProvider timeProvider,
ILogger logger) : ILoginHandler
{
- public async Task> HandleAsync(
+ public async Task> HandleAsync(
LoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -70,7 +76,12 @@ public async Task> HandleAsync(
var tenantInfo = multiTenantContextAccessor.MultiTenantContext?.TenantInfo;
if (tenantInfo == null || string.IsNullOrEmpty(tenantInfo.Id))
{
- return Result.Failure("Tenant not resolved", StatusCodes.Status400BadRequest);
+ return IdmtErrors.Tenant.NotResolved;
+ }
+
+ if (tenantInfo is not IdmtTenantInfo idmtTenant || !idmtTenant.IsActive)
+ {
+ return IdmtErrors.Tenant.Inactive;
}
// Find user by email or username
@@ -84,9 +95,9 @@ public async Task> HandleAsync(
{
user = await userManager.FindByNameAsync(request.Username);
}
- if (user == null)
+ if (user is null || !user.IsActive)
{
- return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized);
+ return IdmtErrors.Auth.Unauthorized;
}
var result = await signInManager.CheckPasswordSignInAsync(
@@ -94,28 +105,47 @@ public async Task> HandleAsync(
request.Password,
lockoutOnFailure: true);
+ if (result.IsLockedOut)
+ {
+ return IdmtErrors.Auth.LockedOut;
+ }
+
if (result.RequiresTwoFactor)
{
+ if (await userManager.IsLockedOutAsync(user))
+ {
+ return IdmtErrors.Auth.LockedOut;
+ }
+
if (!string.IsNullOrEmpty(request.TwoFactorCode))
{
- result = await signInManager.TwoFactorAuthenticatorSignInAsync(request.TwoFactorCode, request.RememberMe, request.RememberMe);
+ var isValid = await userManager.VerifyTwoFactorTokenAsync(
+ user, userManager.Options.Tokens.AuthenticatorTokenProvider, request.TwoFactorCode);
+ if (!isValid)
+ {
+ await userManager.AccessFailedAsync(user);
+ return IdmtErrors.Auth.Unauthorized;
+ }
}
else if (!string.IsNullOrEmpty(request.TwoFactorRecoveryCode))
{
- result = await signInManager.TwoFactorRecoveryCodeSignInAsync(request.TwoFactorRecoveryCode);
+ var redeemResult = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, request.TwoFactorRecoveryCode);
+ if (!redeemResult.Succeeded)
+ {
+ await userManager.AccessFailedAsync(user);
+ return IdmtErrors.Auth.Unauthorized;
+ }
+ }
+ else
+ {
+ return IdmtErrors.Auth.TwoFactorRequired;
}
- }
- if (!result.Succeeded)
- {
- return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized);
+ await userManager.ResetAccessFailedCountAsync(user);
}
-
- // Check if user is active
- if (!user.IsActive)
+ else if (!result.Succeeded)
{
- logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id);
- return Result.Failure("User is deactivated", StatusCodes.Status403Forbidden);
+ return IdmtErrors.Auth.Unauthorized;
}
// Direct cookie sign-in (no middleware delay)
@@ -126,19 +156,23 @@ await signInManager.Context.SignInAsync(
new AuthenticationProperties
{
IsPersistent = request.RememberMe,
- ExpiresUtc = DT.UtcNow.AddDays(30)
+ ExpiresUtc = timeProvider.GetUtcNow().Add(idmtOptions.Value.Identity.Cookie.ExpireTimeSpan)
});
// Update last login timestamp
- user.LastLoginAt = DT.UtcNow;
- await userManager.UpdateAsync(user);
+ user.LastLoginAt = timeProvider.GetUtcNow();
+ var loginUpdateResult = await userManager.UpdateAsync(user);
+ if (!loginUpdateResult.Succeeded)
+ {
+ logger.LogWarning("Failed to update LastLoginAt for user {UserId}", user.Id);
+ }
- return Result.Success(new LoginResponse { UserId = user.Id });
+ return new LoginResponse { UserId = user.Id };
}
catch (Exception ex)
{
- logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? "unknown", request.Username ?? "unknown");
- return Result.Failure("An error occurred during login", StatusCodes.Status500InternalServerError);
+ logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", PiiMasker.MaskEmail(request.Email), PiiMasker.MaskEmail(request.Username));
+ return IdmtErrors.General.Unexpected;
}
}
}
@@ -151,7 +185,7 @@ internal sealed class TokenLoginHandler(
TimeProvider timeProvider,
ILogger logger) : ITokenLoginHandler
{
- public async Task> HandleAsync(
+ public async Task> HandleAsync(
LoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -161,7 +195,12 @@ public async Task> HandleAsync(
var tenantInfo = multiTenantContextAccessor.MultiTenantContext?.TenantInfo;
if (tenantInfo == null || string.IsNullOrEmpty(tenantInfo.Id))
{
- return Result.Failure("Tenant not resolved", StatusCodes.Status400BadRequest);
+ return IdmtErrors.Tenant.NotResolved;
+ }
+
+ if (tenantInfo is not IdmtTenantInfo idmtTenant || !idmtTenant.IsActive)
+ {
+ return IdmtErrors.Tenant.Inactive;
}
// Find user by email or username
@@ -175,9 +214,9 @@ public async Task> HandleAsync(
{
user = await userManager.FindByNameAsync(request.Username);
}
- if (user == null)
+ if (user == null || !user.IsActive)
{
- return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized);
+ return IdmtErrors.Auth.Unauthorized;
}
var result = await signInManager.CheckPasswordSignInAsync(
@@ -185,28 +224,47 @@ public async Task> HandleAsync(
request.Password,
lockoutOnFailure: true);
+ if (result.IsLockedOut)
+ {
+ return IdmtErrors.Auth.LockedOut;
+ }
+
if (result.RequiresTwoFactor)
{
+ if (await userManager.IsLockedOutAsync(user))
+ {
+ return IdmtErrors.Auth.LockedOut;
+ }
+
if (!string.IsNullOrEmpty(request.TwoFactorCode))
{
- result = await signInManager.TwoFactorAuthenticatorSignInAsync(request.TwoFactorCode, request.RememberMe, request.RememberMe);
+ var isValid = await userManager.VerifyTwoFactorTokenAsync(
+ user, userManager.Options.Tokens.AuthenticatorTokenProvider, request.TwoFactorCode);
+ if (!isValid)
+ {
+ await userManager.AccessFailedAsync(user);
+ return IdmtErrors.Auth.Unauthorized;
+ }
}
else if (!string.IsNullOrEmpty(request.TwoFactorRecoveryCode))
{
- result = await signInManager.TwoFactorRecoveryCodeSignInAsync(request.TwoFactorRecoveryCode);
+ var redeemResult = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, request.TwoFactorRecoveryCode);
+ if (!redeemResult.Succeeded)
+ {
+ await userManager.AccessFailedAsync(user);
+ return IdmtErrors.Auth.Unauthorized;
+ }
+ }
+ else
+ {
+ return IdmtErrors.Auth.TwoFactorRequired;
}
- }
- if (!result.Succeeded)
- {
- return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized);
+ await userManager.ResetAccessFailedCountAsync(user);
}
-
- // Check if user is active
- if (!user.IsActive)
+ else if (!result.Succeeded)
{
- logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id);
- return Result.Failure("User is deactivated", StatusCodes.Status403Forbidden);
+ return IdmtErrors.Auth.Unauthorized;
}
// Generate tokens using BearerToken
@@ -239,82 +297,68 @@ public async Task> HandleAsync(
var refreshToken = refreshTokenProtector.Protect(refreshTicket);
// Update last login timestamp
- user.LastLoginAt = DT.UtcNow;
- await userManager.UpdateAsync(user);
+ user.LastLoginAt = timeProvider.GetUtcNow();
+ var tokenLoginUpdateResult = await userManager.UpdateAsync(user);
+ if (!tokenLoginUpdateResult.Succeeded)
+ {
+ logger.LogWarning("Failed to update LastLoginAt for user {UserId}", user.Id);
+ }
var expiresIn = (long)bearerOptions.BearerTokenExpiration.TotalSeconds;
- return Result.Success(new AccessTokenResponse
+ return new AccessTokenResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn,
TokenType = "Bearer"
- });
+ };
}
catch (Exception ex)
{
- logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? request.Username ?? "unknown", request.Email ?? request.Username ?? "unknown");
- return Result.Failure("An error occurred during login", StatusCodes.Status500InternalServerError);
+ logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", PiiMasker.MaskEmail(request.Email), PiiMasker.MaskEmail(request.Username));
+ return IdmtErrors.General.Unexpected;
}
}
}
- ///
- /// Validate the login request.
- ///
- /// A list of validation errors or null if the request is valid.
- public static Dictionary? Validate(this LoginRequest request)
- {
- var errors = new Dictionary();
-
- if (request.Email is null && request.Username is null)
- {
- errors["Identifier"] = ["Email or Username is required."];
- }
- else
- {
- if (request.Email is not null && !Validators.IsValidEmail(request.Email))
- {
- errors["Email"] = ["Invalid email."];
- }
-
- if (request.Username is not null && !Validators.IsValidUsername(request.Username))
- {
- errors["Username"] = ["Invalid username."];
- }
- }
-
- if (string.IsNullOrWhiteSpace(request.Password))
- {
- errors["Password"] = ["Password is required."];
- }
-
- return errors.Count == 0 ? null : errors;
- }
-
public static RouteHandlerBuilder MapCookieLoginEndpoint(this IEndpointRouteBuilder endpoints)
{
return endpoints.MapPost("/login", async Task, UnauthorizedHttpResult, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> (
[FromBody] LoginRequest request,
[FromServices] ILoginHandler handler,
+ [FromServices] IValidator validator,
HttpContext context) =>
{
- if (request.Validate() is { } validationErrors)
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}
var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
- if (!response.IsSuccess)
+ if (response.IsError)
{
- return response.StatusCode switch
+ if (response.FirstError.Code == "Auth.LockedOut")
+ {
+ return TypedResults.Problem(
+ response.FirstError.Description,
+ statusCode: StatusCodes.Status429TooManyRequests);
+ }
+ if (response.FirstError.Code == "Auth.TwoFactorRequired")
+ {
+ return TypedResults.Problem(
+ response.FirstError.Description,
+ statusCode: StatusCodes.Status422UnprocessableEntity,
+ title: response.FirstError.Code);
+ }
+ return response.FirstError.Type switch
{
- StatusCodes.Status401Unauthorized => TypedResults.Unauthorized(),
- StatusCodes.Status403Forbidden => TypedResults.Forbid(),
- _ => TypedResults.Problem(response.ErrorMessage, statusCode: response.StatusCode),
+ ErrorType.Unauthorized => TypedResults.Unauthorized(),
+ ErrorType.Forbidden => TypedResults.Forbid(),
+ ErrorType.Validation => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status400BadRequest),
+ _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError),
};
}
- return TypedResults.Ok(response.Value!);
+ return TypedResults.Ok(response.Value);
})
.WithSummary("Login user")
.WithDescription("Authenticate user and return cookie");
@@ -325,25 +369,40 @@ public static RouteHandlerBuilder MapTokenLoginEndpoint(this IEndpointRouteBuild
return endpoints.MapPost("/token", async Task, UnauthorizedHttpResult, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> (
[FromBody] LoginRequest request,
[FromServices] ITokenLoginHandler handler,
+ [FromServices] IValidator validator,
HttpContext context) =>
{
- if (request.Validate() is { } validationErrors)
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}
var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
- if (!response.IsSuccess)
+ if (response.IsError)
{
- return response.StatusCode switch
+ if (response.FirstError.Code == "Auth.LockedOut")
+ {
+ return TypedResults.Problem(
+ response.FirstError.Description,
+ statusCode: StatusCodes.Status429TooManyRequests);
+ }
+ if (response.FirstError.Code == "Auth.TwoFactorRequired")
+ {
+ return TypedResults.Problem(
+ response.FirstError.Description,
+ statusCode: StatusCodes.Status422UnprocessableEntity,
+ title: response.FirstError.Code);
+ }
+ return response.FirstError.Type switch
{
- StatusCodes.Status401Unauthorized => TypedResults.Unauthorized(),
- StatusCodes.Status403Forbidden => TypedResults.Forbid(),
- _ => TypedResults.Problem(response.ErrorMessage, statusCode: response.StatusCode),
+ ErrorType.Unauthorized => TypedResults.Unauthorized(),
+ ErrorType.Forbidden => TypedResults.Forbid(),
+ ErrorType.Validation => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status400BadRequest),
+ _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError),
};
}
- return TypedResults.Ok(response.Value!);
+ return TypedResults.Ok(response.Value);
})
.WithSummary("Login user")
.WithDescription("Authenticate user and return bearer token");
}
-}
\ No newline at end of file
+}
diff --git a/src/Idmt.Plugin/Features/Auth/Logout.cs b/src/Idmt.Plugin/Features/Auth/Logout.cs
index b7f3605..28da8bb 100644
--- a/src/Idmt.Plugin/Features/Auth/Logout.cs
+++ b/src/Idmt.Plugin/Features/Auth/Logout.cs
@@ -1,4 +1,9 @@
+using ErrorOr;
+using Finbuckle.MultiTenant.Abstractions;
+using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
@@ -6,50 +11,86 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
namespace Idmt.Plugin.Features.Auth;
public static class Logout
{
-
- ///
- /// Interface for logout operations
- ///
public interface ILogoutHandler
{
- ///
- /// Handles user logout
- ///
- /// Cancellation token
- Task HandleAsync(CancellationToken cancellationToken = default);
+ Task> HandleAsync(CancellationToken cancellationToken = default);
}
- internal sealed class LogoutHandler(ILogger logger, SignInManager signInManager)
- : ILogoutHandler
+ internal sealed class LogoutHandler(
+ ILogger logger,
+ SignInManager signInManager,
+ ICurrentUserService currentUserService,
+ IMultiTenantContextAccessor tenantContextAccessor,
+ IOptions idmtOptions,
+ ITokenRevocationService tokenRevocationService)
+ : ILogoutHandler
{
- public async Task HandleAsync(CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(CancellationToken cancellationToken = default)
{
try
{
+ if (currentUserService.UserId is { } userId)
+ {
+ // Primary resolution: use the Finbuckle multi-tenant context, which provides
+ // the database Id required by the token revocation store.
+ var tenantId = tenantContextAccessor.MultiTenantContext?.TenantInfo?.Id;
+
+ if (tenantId is not null)
+ {
+ await tokenRevocationService.RevokeUserTokensAsync(userId, tenantId, cancellationToken);
+ }
+ else
+ {
+ // Fallback: the multi-tenant strategy did not resolve a tenant context
+ // (e.g. header or route strategies at logout time). Read the tenant claim
+ // from the bearer principal to produce a meaningful diagnostic. Token
+ // revocation cannot proceed without the tenant DB Id, so log a warning
+ // rather than silently succeeding with an unrevoked token.
+ var tenantClaimKey = idmtOptions.Value.MultiTenant.StrategyOptions
+ .GetValueOrDefault(IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim);
+ var tenantIdentifierFromClaim = currentUserService.User?.FindFirst(tenantClaimKey)?.Value;
+
+ logger.LogWarning(
+ "Token revocation skipped for user {UserId}: tenant context could not be resolved. " +
+ "Tenant identifier from bearer claims: {TenantIdentifier}. " +
+ "Ensure the multi-tenant strategy resolves during logout requests, " +
+ "or add the claim strategy so the tenant can be resolved from the bearer token.",
+ userId,
+ tenantIdentifierFromClaim ?? "");
+ }
+ }
+
await signInManager.SignOutAsync();
+ return Result.Success;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred during logout");
- throw;
+ return IdmtErrors.General.Unexpected;
}
}
}
public static RouteHandlerBuilder MapLogoutEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapPost("/logout", async Task (
+ return endpoints.MapPost("/logout", async Task> (
[FromServices] ILogoutHandler logoutHandler,
CancellationToken cancellationToken = default) =>
{
- await logoutHandler.HandleAsync(cancellationToken);
+ var result = await logoutHandler.HandleAsync(cancellationToken);
+ if (result.IsError)
+ {
+ return TypedResults.Problem(result.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError);
+ }
return TypedResults.NoContent();
})
+ .RequireAuthorization()
.WithSummary("Logout user")
.WithDescription("Logout user and invalidate bearer token or cookie");
}
diff --git a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs
index 7dc161f..94b8f01 100644
--- a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs
+++ b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs
@@ -1,5 +1,12 @@
using System.Security.Claims;
+using ErrorOr;
+using Finbuckle.MultiTenant.Abstractions;
+using FluentValidation;
+using Idmt.Plugin.Configuration;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
+using Idmt.Plugin.Services;
+using Idmt.Plugin.Validation;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -19,16 +26,19 @@ public sealed record RefreshTokenResponse(ClaimsPrincipal ClaimsPrincipal);
public interface IRefreshTokenHandler
{
- Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
+ Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
}
internal sealed class RefreshTokenHandler(
IOptionsMonitor bearerTokenOptions,
TimeProvider timeProvider,
- SignInManager signInManager)
+ SignInManager signInManager,
+ IMultiTenantContextAccessor tenantContextAccessor,
+ IOptions idmtOptions,
+ ITokenRevocationService tokenRevocationService)
: IRefreshTokenHandler
{
- public async Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
+ public async Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
{
var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector;
var refreshTicket = refreshTokenProtector.Unprotect(request.RefreshToken);
@@ -37,47 +47,61 @@ public async Task> HandleAsync(RefreshTokenRequest
timeProvider.GetUtcNow() >= expiresUtc ||
await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not IdmtUser user)
{
- return Result.Failure("Invalid refresh token", StatusCodes.Status400BadRequest);
+ return IdmtErrors.Token.Invalid;
}
- ClaimsPrincipal claimsPrincipal = await signInManager.CreateUserPrincipalAsync(user);
- return Result.Success(new RefreshTokenResponse(claimsPrincipal));
- }
- }
+ if (!user.IsActive)
+ {
+ return IdmtErrors.Auth.Unauthorized;
+ }
- public static Dictionary? Validate(this RefreshTokenRequest request)
- {
- if (string.IsNullOrEmpty(request.RefreshToken))
- {
- return new Dictionary
+ // Validate tenant context matches refresh token
+ var tenantClaimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(
+ IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim);
+ var tokenTenantClaim = refreshTicket.Principal?.FindFirst(tenantClaimKey)?.Value;
+ var currentTenant = tenantContextAccessor.MultiTenantContext?.TenantInfo?.Identifier;
+
+ if (tokenTenantClaim is null || currentTenant is null || tokenTenantClaim != currentTenant)
{
- ["RefreshToken"] = ["Refresh token is required."]
- };
+ return IdmtErrors.Auth.Unauthorized;
+ }
+
+ // Check if this token has been revoked
+ var issuedAt = refreshTicket.Properties.IssuedUtc
+ ?? expiresUtc - idmtOptions.Value.Identity.Bearer.RefreshTokenExpiration;
+ var tenantId = tenantContextAccessor.MultiTenantContext?.TenantInfo?.Id;
+
+ if (tenantId is not null && await tokenRevocationService.IsTokenRevokedAsync(user.Id, tenantId, issuedAt, cancellationToken))
+ {
+ return IdmtErrors.Token.Revoked;
+ }
+
+ ClaimsPrincipal claimsPrincipal = await signInManager.CreateUserPrincipalAsync(user);
+ return new RefreshTokenResponse(claimsPrincipal);
}
- return null;
}
public static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoints)
{
- return endpoints.MapPost("/refresh", async Task, SignInHttpResult, ChallengeHttpResult, ValidationProblem>> (
+ return endpoints.MapPost("/refresh", async Task> (
[FromBody] RefreshTokenRequest request,
[FromServices] IRefreshTokenHandler handler,
+ [FromServices] IValidator validator,
HttpContext context) =>
{
- if (request.Validate() is { } validationErrors)
+ if (ValidationHelper.Validate(request, validator) is { } validationErrors)
{
return TypedResults.ValidationProblem(validationErrors);
}
var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted);
- if (!response.IsSuccess)
+ if (response.IsError)
{
return TypedResults.Challenge();
}
- return TypedResults.SignIn(response.Value!.ClaimsPrincipal, authenticationScheme: IdentityConstants.BearerScheme);
+ return TypedResults.SignIn(response.Value.ClaimsPrincipal, authenticationScheme: IdentityConstants.BearerScheme);
})
.WithSummary("Refresh token")
- .WithDescription("Refresh JWT token using refresh token")
- .RequireAuthorization();
+ .WithDescription("Refresh JWT token using refresh token");
}
-}
\ No newline at end of file
+}
diff --git a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs b/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs
index ee7a364..0bbd4d9 100644
--- a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs
+++ b/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs
@@ -1,4 +1,6 @@
-using System.Text.Encodings.Web;
+using ErrorOr;
+using FluentValidation;
+using Idmt.Plugin.Errors;
using Idmt.Plugin.Models;
using Idmt.Plugin.Services;
using Idmt.Plugin.Validation;
@@ -18,8 +20,7 @@ public sealed record ResendConfirmationEmailRequest(string Email);
public interface IResendConfirmationEmailHandler
{
- Task HandleAsync(
- bool useApiLinks,
+ Task> HandleAsync(
ResendConfirmationEmailRequest request,
CancellationToken cancellationToken = default);
}
@@ -31,8 +32,7 @@ internal sealed class ResendConfirmationEmailHandler(
ILogger logger
) : IResendConfirmationEmailHandler
{
- public async Task