From 644a2ba5365ad22b9368cd97f5855242de995a2b Mon Sep 17 00:00:00 2001 From: iuri dotta Date: Fri, 6 Mar 2026 12:08:40 -0300 Subject: [PATCH] feat: add DiscoverTenants endpoint for pre-login tenant discovery - Implemented DiscoverTenants feature with request and response models. - Added DiscoverTenantsHandler to handle tenant discovery logic. - Created DiscoverTenantsRequestValidator for input validation. - Integrated DiscoverTenants endpoint into AuthEndpoints. - Updated sample application to demonstrate DiscoverTenants functionality. - Added unit tests for DiscoverTenantsHandler to ensure correct behavior. - Enhanced logging and error handling for tenant discovery process. --- README.md | 330 +++++++++++------- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Features/Auth/DiscoverTenants.cs | 123 +++++++ src/Idmt.Plugin/Features/AuthEndpoints.cs | 1 + .../DiscoverTenantsRequestValidator.cs | 13 + .../Idmt.BasicSample/Idmt.BasicSample.csproj | 1 + src/samples/Idmt.BasicSample/Program.cs | 107 +++++- .../appsettings.Development.json | 21 +- src/samples/Idmt.BasicSample/appsettings.json | 65 +++- .../Idmt.BasicSample/wwwroot/index.html | 8 + .../Idmt.BasicSample/wwwroot/js/api-client.js | 12 +- .../Auth/DiscoverTenantsHandlerTests.cs | 289 +++++++++++++++ 12 files changed, 818 insertions(+), 153 deletions(-) create mode 100644 src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs create mode 100644 src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs create mode 100644 src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs diff --git a/README.md b/README.md index bcc86cc..827494e 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,63 @@ # IDMT Plugin -Identity MultiTenant plugin library for ASP.NET Core that provides automatic identity management (authentication and authorization) and multi-tenancy support using Finbuckle.MultiTenant and Microsoft.AspNetCore.Identity. +An opinionated .NET 10 library for self-hosted identity management and multi-tenancy. Built on top of ASP.NET Core Identity and Finbuckle.MultiTenant, it exposes a complete set of Minimal API endpoints for authentication, user management, and tenant administration with minimal configuration. -## Features +**Key features:** -- **Multi-Tenant Support**: Built-in multi-tenancy using Finbuckle.MultiTenant. -- **Identity Management**: ASP.NET Core Identity integration with support for both **Bearer Token** (JWT) and **Cookie** authentication. -- **Vertical Slice Architecture**: Each identity endpoint has its own handler interface and implementation. -- **Minimal APIs**: Modern endpoint routing with clean, composable APIs. -- **Configurable**: Extensive configuration options for identity, cookies, and multi-tenancy strategies. -- **Database Agnostic**: Works with any Entity Framework Core provider. +- Dual authentication: cookie-based and bearer token (opaque), resolved automatically per request +- Multi-tenancy via header, claim, route, or base-path strategies (Finbuckle.MultiTenant) +- Vertical slice architecture — each endpoint is a self-contained handler +- Per-IP fixed-window rate limiting on all auth endpoints +- Token revocation on logout with background cleanup +- Account lockout (5 failed attempts / 5-minute window) +- PII masking in all structured log output +- Audit logging on all entity mutations +- Per-tenant cookie isolation and bearer token tenant validation +- Security headers on every response -## Quick Start +--- -### 1. Install the Package +## Quick Start ```bash dotnet add package Idmt.Plugin ``` -### 2. Configure Services - -In your `Program.cs`, add the IDMT services. You generally need to provide your own DbContext that inherits from `IdmtDbContext`. - ```csharp -using Idmt.Plugin.Extensions; - +// Program.cs var builder = WebApplication.CreateBuilder(args); -// Add IDMT services with your custom DbContext builder.Services.AddIdmt( builder.Configuration, - // Configure your database provider (e.g., SQL Server, PostgreSQL, SQLite) - options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")) + db => db.UseSqlServer(connectionString) ); var app = builder.Build(); -// Configure the HTTP request pipeline app.UseIdmt(); +app.MapGroup("").MapIdmtEndpoints(); -// Ensure database is created and seeded (optional helper) -app.EnsureIdmtDatabase(); -app.SeedIdmtData(); +await app.EnsureIdmtDatabaseAsync(); +await app.SeedIdmtDataAsync(); app.Run(); ``` -### 3. Application Configuration +`MyDbContext` must extend `IdmtDbContext`. Call `AddIdmt` (without the generic parameter) to use the base context directly. + +--- -Add the `Idmt` section to your `appsettings.json`. Below is a comprehensive example with default or common values: +## Configuration ```json { "Idmt": { "Application": { + "ApiPrefix": "/api/v1", "ClientUrl": "https://myapp.com", + "ConfirmEmailFormPath": "/confirm-email", "ResetPasswordFormPath": "/reset-password", - "ConfirmEmailFormPath": "/confirm-email" + "EmailConfirmationMode": "ClientForm" }, "Identity": { "Password": { @@ -65,160 +65,240 @@ Add the `Idmt` section to your `appsettings.json`. Below is a comprehensive exam "RequireLowercase": true, "RequireUppercase": true, "RequireNonAlphanumeric": false, - "RequiredLength": 6 - }, - "User": { - "RequireUniqueEmail": true + "RequiredLength": 8, + "RequiredUniqueChars": 1 }, "SignIn": { - "RequireConfirmedEmail": false + "RequireConfirmedEmail": true }, "Cookie": { "Name": ".Idmt.Application", "HttpOnly": true, - "SameSite": "Lax", + "SameSite": "Strict", "ExpireTimeSpan": "14.00:00:00", - "SlidingExpiration": true, - "IsRedirectEnabled": false + "SlidingExpiration": true }, "Bearer": { "BearerTokenExpiration": "01:00:00", "RefreshTokenExpiration": "30.00:00:00" - } + }, + "ExtraRoles": [] }, "MultiTenant": { - "DefaultTenantId": "system-tenant", - "Strategies": ["header", "route", "claim"], + "DefaultTenantName": "System Tenant", + "Strategies": ["header", "claim", "route"], "StrategyOptions": { - "HeaderName": "__tenant__", - "RouteParameter": "__tenant__", - "ClaimType": "tenant" + "header": "__tenant__", + "claim": "tenant", + "route": "__tenant__" } }, "Database": { - "AutoMigrate": false + "DatabaseInitialization": "Migrate" + }, + "RateLimiting": { + "Enabled": true, + "PermitLimit": 10, + "WindowInSeconds": 60 } } } ``` +**Key options:** + +- `ApiPrefix` — URI prefix applied to all endpoint groups (`/auth`, `/manage`, `/admin`, `/healthz`). Set to `""` to remove the prefix. +- `EmailConfirmationMode` — `ServerConfirm` sends a GET link that confirms directly on the server; `ClientForm` sends a link to `ClientUrl/ConfirmEmailFormPath` for SPA handling (default). +- `DatabaseInitialization` — `Migrate` runs pending EF Core migrations (production default); `EnsureCreated` skips migrations (development/testing); `None` leaves schema management to the consumer. +- `Strategies` — ordered list of tenant resolution strategies. Valid values: `header`, `claim`, `route`, `basepath`. +- `RateLimiting` — per-IP fixed-window limiter applied to all `/auth` endpoints. Set `Enabled: false` to opt out. + +--- + ## API Reference -The plugin exposes several groups of endpoints. +All endpoints are mounted under `ApiPrefix` (default `/api/v1`). + +### Authentication — `/auth` -### Authentication (`/auth`) +Rate-limited. All endpoints are public except `/auth/logout`. -Public endpoints for user authentication and account recovery. +| Method | Path | Auth Required | Description | +|--------|------|:---:|-------------| +| POST | /auth/login | - | Cookie login. Returns `{ userId }` and sets the auth cookie. | +| POST | /auth/token | - | Bearer token login. Returns `{ accessToken, refreshToken, expiresIn, tokenType }`. | +| POST | /auth/logout | Yes | Signs out and revokes bearer token. | +| POST | /auth/refresh | - | Exchange a refresh token for a new bearer token. | +| POST | /auth/confirm-email | - | Confirm email address (Base64URL-encoded token in request body). | +| GET | /auth/confirm-email | - | Direct server-side email confirmation via query string (`tenantIdentifier`, `email`, `token`). Used when `EmailConfirmationMode` is `ServerConfirm`. | +| POST | /auth/resend-confirmation-email | - | Resend the confirmation email. | +| POST | /auth/forgot-password | - | Send a password reset email. | +| POST | /auth/reset-password | - | Reset password with a Base64URL-encoded token. | -| Method | Endpoint | Description | Request Body | Response | -|--------|----------|-------------|--------------|----------| -| `POST` | `/auth/login` | Authenticate user with cookie | `email` or `username`, `password`, `rememberMe` (optional) | `LoginResponse` (sets authentication cookie) | -| `POST` | `/auth/token` | Authenticate user and get bearer token | `email` or `username`, `password`, `rememberMe` (optional) | `AccessTokenResponse` (access token, refresh token, expires in) | -| `POST` | `/auth/logout` | Logout user (cookie-based) | - | No content | -| `POST` | `/auth/refresh` | Refresh JWT token | `refreshToken` | `AccessTokenResponse` | -| `POST` | `/auth/forgotPassword` | Request password reset | `email`
Query: `useApiLinks` (true/false) | `ForgotPasswordResponse` | -| `POST` | `/auth/resetPassword` | Reset password with token | Query: `tenantId`, `email`, `token`
Body: `newPassword` | `ResetPasswordResponse` | -| `GET` | `/auth/confirmEmail` | Confirm email address | Query: `tenantId`, `email`, `token` | `ConfirmEmailResponse` | -| `POST` | `/auth/resendConfirmationEmail` | Resend confirmation | Body: `email`
Query: `useApiLinks` | `ResendConfirmationEmailResponse` | +Login requests accept `email` or `username`, `password`, `rememberMe`, and optionally `twoFactorCode` / `twoFactorRecoveryCode`. -### User Management (`/manage`) +### User Management — `/manage` -Endpoints for managing user profiles and accounts. -* **Authorization**: Some endpoints require specific roles (`SysAdmin`, `TenantAdmin`). +All endpoints require authentication. -| Method | Endpoint | Policy | Description | -|--------|----------|--------|-------------| -| `GET` | `/manage/info` | Authenticated | Get current user's info | -| `PUT` | `/manage/info` | Authenticated | Update current user's info | -| `POST` | `/manage/users` | `RequireSysUser` | Register a new user (Admin only) | -| `PUT` | `/manage/users/{id}` | `RequireTenantManager` | Activate/Deactivate user | -| `DELETE` | `/manage/users/{id}` | `RequireTenantManager` | Delete a user | +| Method | Path | Policy | Description | +|--------|------|--------|-------------| +| GET | /manage/info | Authenticated | Get the current user's profile. | +| PUT | /manage/info | Authenticated | Update profile, email, or password. | +| POST | /manage/users | TenantManager | Register a new user (invite flow — sends password-setup email). | +| PUT | /manage/users/{id} | TenantManager | Activate or deactivate a user. | +| DELETE | /manage/users/{id} | TenantManager | Soft-delete a user. | -### System & Tenant Access (`/admin`) +### Administration — `/admin` -System-level endpoints for managing tenant access. -* **Authorization**: `RequireAdminUser` (SysAdmin or SysSupport roles). +All endpoints require the `RequireSysUser` policy (`SysAdmin` or `SysSupport` role). -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/admin/info` | Get system version and environment info | -| `GET` | `/admin/users/{id}/tenants` | List tenants accessible by a user | -| `POST` | `/admin/users/{id}/tenants/{tenantId}` | Grant user access to a tenant | -| `DELETE` | `/admin/users/{id}/tenants/{tenantId}` | Revoke user access to a tenant | +| Method | Path | Description | +|--------|------|-------------| +| POST | /admin/tenants | Create a new tenant. | +| DELETE | /admin/tenants/{identifier} | Soft-delete a tenant. | +| GET | /admin/tenants | List all tenants (paginated; query params: `page`, `pageSize`, max 100). | +| GET | /admin/users/{id}/tenants | List tenants accessible by a user. | +| POST | /admin/users/{id}/tenants/{identifier} | Grant a user access to a tenant. | +| DELETE | /admin/users/{id}/tenants/{identifier} | Revoke a user's access to a tenant. | + +### Health — `/healthz` + +Requires `RequireSysUser`. Returns database connectivity status via ASP.NET Core Health Checks. + +--- ## Authorization Policies -The plugin comes with pre-configured authorization policies based on roles: +| Policy | Roles | +|--------|-------| +| `RequireSysAdmin` | SysAdmin | +| `RequireSysUser` | SysAdmin, SysSupport | +| `RequireTenantManager` | SysAdmin, SysSupport, TenantAdmin | -- **`RequireAuthenticatedUser`**: Any authenticated user. -- **`RequireSysAdmin`**: Users with `SysAdmin` role. -- **`RequireSysUser`**: Users with `SysAdmin` or `SysSupport` roles. -- **`RequireTenantManager`**: Users with `SysAdmin`, `SysSupport`, or `TenantAdmin` roles. +Default roles seeded at startup: `SysAdmin`, `SysSupport`, `TenantAdmin`. Add custom roles via `Identity.ExtraRoles` in configuration. -## Architecture +The default authentication scheme (`CookieOrBearer`) routes to bearer token authentication when an `Authorization: Bearer` header is present, and falls back to cookie authentication otherwise. -### Multi-Tenancy -The library supports multiple tenant resolution strategies out of the box: -- **Header**: Reads tenant ID from a request header (default `__tenant__`). -- **Route**: Reads from a route parameter (default `__tenant__`). -- **Claim**: Reads from the user's claims (useful for JWTs). +--- -### Authentication Strategies -The plugin uses a hybrid approach with separate endpoints for each authentication method: -- **Cookie Authentication** (`/auth/login`): Sets an authentication cookie directly, ideal for browser-based applications. Returns a `LoginResponse` with user information. -- **Bearer Token Authentication** (`/auth/token`): Returns bearer tokens (access token and refresh token) in the response body, ideal for SPAs and mobile apps. Returns an `AccessTokenResponse` with token details. +## Multi-Tenancy -Both authentication methods use local token/cookie resolution and do not delegate to Identity middleware, providing full control over the authentication flow. +Tenant resolution strategies are evaluated in the order they appear in `Strategies`. The first strategy that resolves a tenant wins. -The `CookieOrBearer` policy automatically selects the scheme based on the `Authorization` header: -- If an `Authorization: Bearer ` header is present, bearer token authentication is used. -- Otherwise, cookie authentication is attempted. +| Strategy | Config key | Default value | +|----------|-----------|---------------| +| `header` | `StrategyOptions.header` | `__tenant__` | +| `claim` | `StrategyOptions.claim` | `tenant` | +| `route` | `StrategyOptions.route` | `__tenant__` | +| `basepath` | — | — | -#### Example: Cookie Authentication +Authentication cookies are isolated per tenant — the cookie name includes the tenant identifier, preventing session leakage across tenants. -```http -POST /auth/login -Content-Type: application/json +**Route strategy example:** -{ - "email": "user@example.com", - "password": "SecurePassword123!" -} +```csharp +app.MapGroup("/{__tenant__}").MapIdmtEndpoints(); ``` -Response: -```json -{ - "userId": "123e4567-e89b-12d3-a456-426614174000" -} +**Bearer token tenant validation:** + +When using bearer tokens, a middleware (`ValidateBearerTokenTenantMiddleware`) validates that the tenant embedded in the token matches the resolved tenant context on every request. + +--- + +## Security + +- Per-IP fixed-window rate limiting on all `/auth` endpoints (configurable via `RateLimiting`) +- `SameSite=Strict` cookies by default — browser never sends the auth cookie on cross-site requests; `SameSiteMode.None` is blocked and falls back to `Strict` +- Security headers on every response: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` +- Token revocation on logout stored in the database; a background `IHostedService` periodically purges expired revoked tokens +- Account lockout: 5 failed attempts triggers a 5-minute lockout +- PII masking: email addresses and other sensitive values are masked in all structured log output +- Audit logging on all entity create/update/delete operations +- Per-tenant cookie isolation: each tenant gets a distinct cookie name +- Bearer token tenant validation middleware on all authenticated bearer requests + +--- + +## Customization + +### Service registration hooks + +```csharp +builder.Services.AddIdmt( + builder.Configuration, + db => db.UseNpgsql(connectionString), + options => + { + options.Application.ApiPrefix = "/api/v2"; + }, + customizeAuth: auth => + { + // Add additional authentication schemes + }, + customizeAuthz: authz => + { + // Add additional authorization policies + } +); ``` -The authentication cookie is automatically set in the response. +### Email delivery + +The library registers a no-op `IEmailSender` by default and logs a startup warning when it is still active. Replace it with a real implementation before calling `app.Run()`: + +```csharp +builder.Services.AddTransient, MySmtpEmailSender>(); +``` -#### Example: Bearer Token Authentication +The sender is used for email confirmation, password reset, and the invite-based user registration flow. -```http -POST /auth/token -Content-Type: application/json +### Database seeding +Pass a custom seed delegate to `SeedIdmtDataAsync` to run additional seeding after the default system tenant is created: + +```csharp +await app.SeedIdmtDataAsync(async services => { - "email": "user@example.com", - "password": "SecurePassword123!" -} + var userManager = services.GetRequiredService>(); + // seed initial admin user, etc. +}); ``` -Response: -```json +### OpenAPI / Swagger + +IDMT does not configure OpenAPI. To expose the bearer token scheme in Swagger UI, register a document transformer in the host application: + +```csharp +builder.Services.AddOpenApi(options => { - "accessToken": "CfDJ8...", - "refreshToken": "CfDJ8...", - "expiresIn": 3600, - "tokenType": "Bearer" -} + options.AddDocumentTransformer((document, context, ct) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "opaque" + }; + return Task.CompletedTask; + }); +}); ``` -Use the `accessToken` in subsequent requests: -```http -GET /manage/info -Authorization: Bearer CfDJ8... -``` +--- + +## Requirements + +- .NET 10 +- Any EF Core-supported database (SQL Server, PostgreSQL, SQLite, etc.) + +**Key dependencies:** + +| Package | Purpose | +|---------|---------| +| `Finbuckle.MultiTenant` | Tenant resolution and per-tenant authentication | +| `Microsoft.AspNetCore.Identity` | User, role, and sign-in management | +| `ErrorOr` | Discriminated union error handling in handlers | +| `FluentValidation` | Request validation | diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs index a97c943..ab98637 100644 --- a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs @@ -478,6 +478,7 @@ private static void RegisterFeatures(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Auth/Manage services.AddScoped(); diff --git a/src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs b/src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs new file mode 100644 index 0000000..90bc8e0 --- /dev/null +++ b/src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs @@ -0,0 +1,123 @@ +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Idmt.Plugin.Validation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Idmt.Plugin.Features.Auth; + +public static class DiscoverTenants +{ + public sealed record DiscoverTenantsRequest(string Email); + + public sealed record TenantItem(string Identifier, string Name); + + public sealed record DiscoverTenantsResponse(IReadOnlyList Tenants); + + public interface IDiscoverTenantsHandler + { + Task> HandleAsync( + DiscoverTenantsRequest request, + CancellationToken cancellationToken = default); + } + + internal sealed class DiscoverTenantsHandler( + IdmtDbContext dbContext, + TimeProvider timeProvider, + ILogger logger) : IDiscoverTenantsHandler + { + public async Task> HandleAsync( + DiscoverTenantsRequest request, + CancellationToken cancellationToken = default) + { + try + { + var normalizedEmail = request.Email.ToUpperInvariant(); + var now = timeProvider.GetUtcNow(); + + // Find all tenant IDs where the user has a direct account. + // IgnoreQueryFilters bypasses Finbuckle's automatic tenant filter + // so we can search across all tenants. + var directTenantIds = await dbContext.Users + .IgnoreQueryFilters() + .Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive) + .Select(u => u.TenantId) + .Distinct() + .ToListAsync(cancellationToken); + + // Find tenant IDs granted via TenantAccess (cross-tenant grants). + // First find user IDs matching the email, then look up their access grants. + var userIds = await dbContext.Users + .IgnoreQueryFilters() + .Where(u => u.NormalizedEmail == normalizedEmail && u.IsActive) + .Select(u => u.Id) + .ToListAsync(cancellationToken); + + var accessTenantIds = await dbContext.TenantAccess + .Where(ta => userIds.Contains(ta.UserId) + && ta.IsActive + && (ta.ExpiresAt == null || ta.ExpiresAt > now)) + .Select(ta => ta.TenantId) + .Distinct() + .ToListAsync(cancellationToken); + + // Union all tenant IDs + var allTenantIds = directTenantIds.Union(accessTenantIds).ToList(); + + if (allTenantIds.Count == 0) + { + return new DiscoverTenantsResponse([]); + } + + // Resolve tenant info, filtering only active tenants + var tenants = await dbContext.Set() + .Where(ti => allTenantIds.Contains(ti.Id) && ti.IsActive) + .OrderBy(ti => ti.Name) + .Select(ti => new TenantItem(ti.Identifier, ti.Name ?? ti.Identifier)) + .ToListAsync(cancellationToken); + + return new DiscoverTenantsResponse(tenants); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during tenant discovery for {Email}", + PiiMasker.MaskEmail(request.Email)); + return IdmtErrors.General.Unexpected; + } + } + } + + public static RouteHandlerBuilder MapDiscoverTenantsEndpoint(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapPost("/discover-tenants", async Task, ValidationProblem, StatusCodeHttpResult>> ( + [FromBody] DiscoverTenantsRequest request, + [FromServices] IDiscoverTenantsHandler handler, + [FromServices] IValidator validator, + HttpContext context) => + { + if (ValidationHelper.Validate(request, validator) is { } validationErrors) + { + return TypedResults.ValidationProblem(validationErrors); + } + + var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); + if (result.IsError) + { + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); + } + + return TypedResults.Ok(result.Value); + }) + .WithSummary("Discover tenants by email") + .WithDescription("Resolve tenant(s) associated with an email address for pre-login discovery"); + } +} diff --git a/src/Idmt.Plugin/Features/AuthEndpoints.cs b/src/Idmt.Plugin/Features/AuthEndpoints.cs index 306a37b..d599597 100644 --- a/src/Idmt.Plugin/Features/AuthEndpoints.cs +++ b/src/Idmt.Plugin/Features/AuthEndpoints.cs @@ -41,5 +41,6 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder endpoints) auth.MapResendConfirmationEmailEndpoint(); auth.MapForgotPasswordEndpoint(); auth.MapResetPasswordEndpoint(); + auth.MapDiscoverTenantsEndpoint(); } } diff --git a/src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs b/src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs new file mode 100644 index 0000000..ced5d8f --- /dev/null +++ b/src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class DiscoverTenantsRequestValidator : AbstractValidator +{ + public DiscoverTenantsRequestValidator() + { + RuleFor(x => x.Email).Must(Validators.IsValidEmail) + .WithMessage("Invalid email address."); + } +} diff --git a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj b/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj index c0aa06c..0d5ea9c 100644 --- a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj +++ b/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj @@ -9,6 +9,7 @@ + diff --git a/src/samples/Idmt.BasicSample/Program.cs b/src/samples/Idmt.BasicSample/Program.cs index 06cbdb6..82f162c 100644 --- a/src/samples/Idmt.BasicSample/Program.cs +++ b/src/samples/Idmt.BasicSample/Program.cs @@ -1,32 +1,107 @@ +// ============================================================ +// Idmt.BasicSample — showcasing the full IDMT plugin feature set +// +// What this sample demonstrates: +// - Cookie + Bearer dual-scheme authentication +// - Multi-tenant resolution via header and claim strategies +// - Role-based authorization (SysAdmin, TenantAdmin, custom roles) +// - Rate limiting on auth endpoints (disabled in Development) +// - Database initialization with EnsureCreated (SQLite) +// - OpenAPI document with Bearer security scheme +// - Seeding a default admin user on first run (SeedTestUser.cs) +// +// Default credentials (seeded on first run): +// Email: testadmin@example.com +// Password: TestAdmin123! +// ============================================================ + using Finbuckle.MultiTenant.AspNetCore.Extensions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Microsoft.OpenApi; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddOpenApi(); +// ---------------------------------------------------------- +// OpenAPI — expose the Bearer security scheme in Swagger UI. +// IDMT does not configure OpenAPI itself; the host app owns it. +// ---------------------------------------------------------- +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, _, _) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "opaque", + Description = "Bearer token obtained from POST /auth/login/token" + }; + return Task.CompletedTask; + }); +}); + +// ---------------------------------------------------------- +// IDMT plugin registration +// +// AddIdmt parameters: +// configureDb — configure the EF Core provider (required) +// configureOptions — override any IdmtOptions value in code, +// applied on top of appsettings.json bindings +// customizeAuthentication / customizeAuthorization — extend +// the auth pipeline with additional schemes +// or policies without replacing the defaults +// ---------------------------------------------------------- builder.Services.AddSingleton(Idmt.BasicSample.SeedTestUser.SeedAsync); -builder.Services.AddIdmt(builder.Configuration, db => db.UseSqlite("Data Source=Idmt.BasicSample.db")); +builder.Services.AddIdmt( + builder.Configuration, + configureDb: db => db.UseSqlite("Data Source=Idmt.BasicSample.db"), + configureOptions: options => + { + // Code-level overrides run after appsettings.json is bound, + // so they always win regardless of environment config files. + + // Example: add application-specific roles that IDMT will seed + // alongside the built-in SysAdmin / TenantAdmin roles. + // options.Identity.ExtraRoles = ["Editor", "Viewer"]; + }); + +// ---------------------------------------------------------- +// HTTP pipeline +// ---------------------------------------------------------- var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { + // /openapi/v1.json — excluded from multi-tenant resolution so it + // is always reachable regardless of the active tenant strategy. app.MapOpenApi().ExcludeFromMultiTenantResolution(); } -// Enable static files and default files +// Serve the bundled HTML/CSS/JS frontend from wwwroot/. app.UseDefaultFiles(); app.UseStaticFiles(); +// Registers security headers, rate limiter (when enabled), multi-tenant +// middleware, authentication, authorization, and IDMT-specific middleware. app.UseIdmt(); -var options = app.Services.GetRequiredService>().Value; +// ---------------------------------------------------------- +// Endpoint routing +// +// When the "route" strategy is active the tenant identifier is +// embedded in the URL path (e.g. /acme/api/v1/auth/login), so +// the endpoint group must expose the {__tenant__} route parameter. +// All other strategies (header, claim, basepath) use a plain group. +// ---------------------------------------------------------- +var idmtOptions = app.Services.GetRequiredService>().Value; -if (options.MultiTenant.Strategies.Contains(IdmtMultiTenantStrategy.Route)) +if (idmtOptions.MultiTenant.Strategies.Contains(IdmtMultiTenantStrategy.Route)) { app.MapGroup("/{__tenant__}").MapIdmtEndpoints(); } @@ -35,18 +110,18 @@ app.MapGroup("").MapIdmtEndpoints(); } +// ---------------------------------------------------------- +// Database initialization and data seeding +// +// EnsureIdmtDatabaseAsync honours the DatabaseInitialization mode +// from configuration (EnsureCreated / Migrate / None). +// SeedIdmtDataAsync creates the default system tenant and then +// runs the optional custom seed delegate registered above. +// ---------------------------------------------------------- await app.EnsureIdmtDatabaseAsync(); - -var seedAction = app.Services.GetService(); -await app.SeedIdmtDataAsync(seedAction); - -// Seed test user in development -// if (app.Environment.IsDevelopment()) -// { -// using var scope = app.Services.CreateScope(); -// await Idmt.BasicSample.SeedTestUser.SeedAsync(scope.ServiceProvider); -// } +await app.SeedIdmtDataAsync(app.Services.GetService()); app.Run(); +// Required by integration tests (WebApplicationFactory). public partial class Program; diff --git a/src/samples/Idmt.BasicSample/appsettings.Development.json b/src/samples/Idmt.BasicSample/appsettings.Development.json index 8417a63..265684d 100644 --- a/src/samples/Idmt.BasicSample/appsettings.Development.json +++ b/src/samples/Idmt.BasicSample/appsettings.Development.json @@ -1,11 +1,9 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, "Idmt": { + "RateLimiting": { + // Disable rate limiting locally so repeated test calls are never rejected. + "Enabled": false + }, "MultiTenant": { "DefaultTenantId": "system-tenant", "Strategies": ["header", "claim"], @@ -14,5 +12,12 @@ "claim": "tenant-identifier" } } - } -} \ No newline at end of file + }, + "Logging": { + "LogLevel": { + "Default": "Information", + // Verbose IDMT logging in development — shows tenant resolution, token handling, etc. + "Idmt": "Debug" + } + }, +} diff --git a/src/samples/Idmt.BasicSample/appsettings.json b/src/samples/Idmt.BasicSample/appsettings.json index 8b5a746..bdbccff 100644 --- a/src/samples/Idmt.BasicSample/appsettings.json +++ b/src/samples/Idmt.BasicSample/appsettings.json @@ -7,13 +7,72 @@ }, "AllowedHosts": "*", "Idmt": { + "Application": { + // URI prefix applied to all IDMT endpoint groups (/auth, /manage, /admin, /health). + // Set to "" to remove the prefix entirely (legacy behaviour). + "ApiPrefix": "", + // Base URL of the client/SPA application. Used when generating email links. + "ClientUrl": "http://localhost:5000", + // Path on the client that renders the "confirm email" form (ClientForm mode only). + "ConfirmEmailFormPath": "/confirm-email", + // Path on the client that renders the "reset password" form. + "ResetPasswordFormPath": "/reset-password", + // ServerConfirm: GET /auth/confirm-email confirms the address directly on the server. + // ClientForm: link points to ClientUrl/ConfirmEmailFormPath — suited for SPAs. + "EmailConfirmationMode": "ServerConfirm" + }, + "Identity": { + "Password": { + "RequireDigit": true, + "RequireLowercase": true, + "RequireUppercase": true, + "RequireNonAlphanumeric": false, + "RequiredLength": 8 + }, + "SignIn": { + // Set to true in production to enforce verified email addresses before login. + "RequireConfirmedEmail": false + }, + "Cookie": { + // Cookie name is automatically suffixed with the tenant identifier at runtime + // to prevent session cross-contamination between tenants. + "Name": ".Idmt.Sample", + // 14-day sliding session — format: "d.hh:mm:ss" + "ExpireTimeSpan": "14.00:00:00" + }, + "Bearer": { + // Short-lived access token (1 hour). + "BearerTokenExpiration": "01:00:00", + // Long-lived refresh token (30 days). Rotate on every use. + "RefreshTokenExpiration": "30.00:00:00" + } + }, "MultiTenant": { - "DefaultTenantId": "system-tenant", - "Strategies": ["header", "claim"], + // Human-readable name for the default (system) tenant created on first run. + "DefaultTenantName": "System Tenant", + // Ordered list of strategies used to resolve the current tenant per request. + // Supported values: "header", "claim", "route", "basepath". + "Strategies": [ "header", "claim" ], + // Per-strategy configuration keys (header name, claim type, route parameter, etc.). "StrategyOptions": { "header": "__tenant-identifier__", "claim": "tenant-identifier" } + }, + "Database": { + // EnsureCreated: creates the schema without migrations — ideal for samples/prototyping. + // Migrate: applies EF Core migrations — recommended for production. + // None: skips initialization entirely (consumer manages schema externally). + "DatabaseInitialization": "EnsureCreated" + }, + "RateLimiting": { + // Fixed-window rate limiter applied to all auth endpoints (login, forgot-password, etc.) + // to protect against brute-force and email-flooding attacks. + "Enabled": true, + // Maximum requests per IP address within the window. + "PermitLimit": 30, + // Length of the fixed window in seconds. + "WindowInSeconds": 60 } } -} \ No newline at end of file +} diff --git a/src/samples/Idmt.BasicSample/wwwroot/index.html b/src/samples/Idmt.BasicSample/wwwroot/index.html index f4408d2..db6aa71 100644 --- a/src/samples/Idmt.BasicSample/wwwroot/index.html +++ b/src/samples/Idmt.BasicSample/wwwroot/index.html @@ -58,6 +58,14 @@

Response

Authentication (/auth)

+
+

POST /auth/discover-tenants

+
+ +
+ +
+

POST /auth/login (Cookie-based)

diff --git a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js b/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js index 2e0eaf8..ec377a4 100644 --- a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js +++ b/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js @@ -27,7 +27,7 @@ function createHeaders(includeAuth = true) { // Add tenant header const tenantId = getTenantId(); if (tenantId) { - headers['__tenant__'] = tenantId; + headers['__tenant-identifier__'] = tenantId; } // Add authorization if needed @@ -124,6 +124,16 @@ function clearToken() { // Authentication Endpoints // ============================================ +async function discoverTenants() { + const email = document.getElementById('discoverEmail').value; + + await apiRequest('/auth/discover-tenants', { + method: 'POST', + includeAuth: false, + body: JSON.stringify({ email }) + }); +} + async function login() { const email = document.getElementById('loginEmail').value; const password = document.getElementById('loginPassword').value; diff --git a/src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs b/src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs new file mode 100644 index 0000000..25e72fb --- /dev/null +++ b/src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs @@ -0,0 +1,289 @@ +using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.EntityFrameworkCore; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Moq; + +namespace Idmt.UnitTests.Features.Auth; + +public class DiscoverTenantsHandlerTests : IDisposable +{ + private readonly IdmtDbContext _dbContext; + private readonly FakeTimeProvider _timeProvider; + private readonly DiscoverTenants.DiscoverTenantsHandler _handler; + + public DiscoverTenantsHandlerTests() + { + var tenantAccessorMock = new Mock(); + var currentUserServiceMock = new Mock(); + + var dummyTenant = new IdmtTenantInfo("sys-id", "system-test", "System Test"); + var dummyContext = new MultiTenantContext(dummyTenant); + tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new IdmtDbContext( + tenantAccessorMock.Object, + options, + currentUserServiceMock.Object, + TimeProvider.System, + NullLogger.Instance); + + // Allow seeding users with any TenantId regardless of current context + _dbContext.TenantMismatchMode = TenantMismatchMode.Ignore; + + _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 3, 6, 12, 0, 0, TimeSpan.Zero)); + + _handler = new DiscoverTenants.DiscoverTenantsHandler( + _dbContext, + _timeProvider, + NullLogger.Instance); + } + + [Fact] + public async Task ReturnsEmptyArray_WhenNoUserMatches() + { + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("unknown@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Empty(result.Value.Tenants); + } + + [Fact] + public async Task ReturnsTenant_WhenUserExistsInOneTenant() + { + // Arrange + var tenantId = Guid.CreateVersion7().ToString(); + _dbContext.Set().Add( + new IdmtTenantInfo(tenantId, "acme-corp", "Acme Corp")); + + _dbContext.Users.Add(new IdmtUser + { + UserName = "alice", + Email = "alice@test.com", + NormalizedEmail = "ALICE@TEST.COM", + IsActive = true, + TenantId = tenantId + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("alice@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Single(result.Value.Tenants); + Assert.Equal("acme-corp", result.Value.Tenants[0].Identifier); + Assert.Equal("Acme Corp", result.Value.Tenants[0].Name); + } + + [Fact] + public async Task ExcludesInactiveUsers() + { + // Arrange + var tenantId = Guid.CreateVersion7().ToString(); + _dbContext.Set().Add( + new IdmtTenantInfo(tenantId, "active-tenant", "Active Tenant")); + + _dbContext.Users.Add(new IdmtUser + { + UserName = "inactive", + Email = "inactive@test.com", + NormalizedEmail = "INACTIVE@TEST.COM", + IsActive = false, + TenantId = tenantId + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("inactive@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Empty(result.Value.Tenants); + } + + [Fact] + public async Task ExcludesInactiveTenants() + { + // Arrange + var tenantId = Guid.CreateVersion7().ToString(); + _dbContext.Set().Add( + new IdmtTenantInfo(tenantId, "dead-tenant", "Dead Tenant") { IsActive = false }); + + _dbContext.Users.Add(new IdmtUser + { + UserName = "bob", + Email = "bob@test.com", + NormalizedEmail = "BOB@TEST.COM", + IsActive = true, + TenantId = tenantId + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("bob@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Empty(result.Value.Tenants); + } + + [Fact] + public async Task IncludesTenantAccessGrants() + { + // Arrange + var homeTenantId = Guid.CreateVersion7().ToString(); + var grantedTenantId = Guid.CreateVersion7().ToString(); + var userId = Guid.NewGuid(); + + _dbContext.Set().AddRange( + new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), + new IdmtTenantInfo(grantedTenantId, "granted-tenant", "Granted Tenant")); + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "charlie", + Email = "charlie@test.com", + NormalizedEmail = "CHARLIE@TEST.COM", + IsActive = true, + TenantId = homeTenantId + }); + + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = grantedTenantId, + IsActive = true, + ExpiresAt = null + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("charlie@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Tenants.Count); + Assert.Contains(result.Value.Tenants, t => t.Identifier == "home-tenant"); + Assert.Contains(result.Value.Tenants, t => t.Identifier == "granted-tenant"); + } + + [Fact] + public async Task ExcludesExpiredTenantAccessGrants() + { + // Arrange + var homeTenantId = Guid.CreateVersion7().ToString(); + var expiredTenantId = Guid.CreateVersion7().ToString(); + var userId = Guid.NewGuid(); + + _dbContext.Set().AddRange( + new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), + new IdmtTenantInfo(expiredTenantId, "expired-tenant", "Expired Tenant")); + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "dave", + Email = "dave@test.com", + NormalizedEmail = "DAVE@TEST.COM", + IsActive = true, + TenantId = homeTenantId + }); + + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = expiredTenantId, + IsActive = true, + ExpiresAt = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc) // yesterday + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("dave@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Single(result.Value.Tenants); + Assert.Equal("home-tenant", result.Value.Tenants[0].Identifier); + } + + [Fact] + public async Task ExcludesInactiveTenantAccessGrants() + { + // Arrange + var homeTenantId = Guid.CreateVersion7().ToString(); + var revokedTenantId = Guid.CreateVersion7().ToString(); + var userId = Guid.NewGuid(); + + _dbContext.Set().AddRange( + new IdmtTenantInfo(homeTenantId, "home-tenant", "Home Tenant"), + new IdmtTenantInfo(revokedTenantId, "revoked-tenant", "Revoked Tenant")); + + _dbContext.Users.Add(new IdmtUser + { + Id = userId, + UserName = "eve", + Email = "eve@test.com", + NormalizedEmail = "EVE@TEST.COM", + IsActive = true, + TenantId = homeTenantId + }); + + _dbContext.TenantAccess.Add(new TenantAccess + { + UserId = userId, + TenantId = revokedTenantId, + IsActive = false, + ExpiresAt = null + }); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("eve@test.com")); + + // Assert + Assert.False(result.IsError); + Assert.Single(result.Value.Tenants); + Assert.Equal("home-tenant", result.Value.Tenants[0].Identifier); + } + + [Fact] + public async Task ReturnsUnexpectedError_WhenExceptionOccurs() + { + // Arrange — dispose the context to force an exception + _dbContext.Dispose(); + + // Act + var result = await _handler.HandleAsync( + new DiscoverTenants.DiscoverTenantsRequest("test@test.com")); + + // Assert + Assert.True(result.IsError); + Assert.Equal("General.Unexpected", result.FirstError.Code); + } + + public void Dispose() + { + _dbContext.Dispose(); + } +}