From 9a90ddae6da329d7c9b67b2751587a76d98f58a2 Mon Sep 17 00:00:00 2001 From: idotta Date: Sun, 15 Mar 2026 01:09:26 -0300 Subject: [PATCH 1/2] Add unit tests for TenantAccessService, TenantOperationService, TokenRevocationCleanupService, TokenRevocationService, and validation logic - Implement tests for TenantAccessService covering access checks and role management. - Create tests for TenantOperationService to validate tenant scope execution. - Add tests for TokenRevocationCleanupService to ensure cleanup logic works as expected. - Develop comprehensive tests for TokenRevocationService to verify token revocation and cleanup behavior. - Introduce FluentValidatorTests and ValidatorsTests to validate request and password validation logic. --- .github/workflows/ci.yml | 12 +-- .github/workflows/publish.yml | 8 +- .../Configuration/IdmtEndpointNames.cs | 0 .../Configuration/IdmtOptions.cs | 2 +- .../Configuration/IdmtOptionsValidator.cs | 11 +-- .../Constants/AuditAction.cs | 0 .../Constants/IdmtClaimTypes.cs | 0 .../Errors/IdmtErrors.cs | 0 .../ApplicationBuilderExtensions.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 0 .../Features/Admin/AdminModels.cs | 0 .../Features/Admin/CreateTenant.cs | 3 +- .../Features/Admin/DeleteTenant.cs | 0 .../Features/Admin/GetAllTenants.cs | 0 .../Features/Admin/GetUserTenants.cs | 0 .../Features/Admin/GrantTenantAccess.cs | 5 +- .../Features/Admin/RevokeTenantAccess.cs | 4 + .../Features/AdminEndpoints.cs | 0 .../Features/Auth/ConfirmEmail.cs | 0 .../Features/Auth/DiscoverTenants.cs | 0 .../Features/Auth/ForgotPassword.cs | 0 .../Features/Auth/Login.cs | 0 .../Features/Auth/Logout.cs | 36 +++++--- .../Features/Auth/RefreshToken.cs | 0 .../Features/Auth/ResendConfirmationEmail.cs | 0 .../Features/Auth/ResetPassword.cs | 0 .../Features/AuthEndpoints.cs | 0 .../Features/Health/BasicHealthCheck.cs | 0 .../Features/Manage/GetUserInfo.cs | 0 .../Features/Manage/RegisterUser.cs | 0 .../Features/Manage/UnregisterUser.cs | 0 .../Features/Manage/UpdateUser.cs | 0 .../Features/Manage/UpdateUserInfo.cs | 8 ++ .../Features/ManageEndpoints.cs | 0 .../Idmt.Plugin.csproj | 2 +- .../Middleware/CurrentUserMiddleware.cs | 0 .../ValidateBearerTokenTenantMiddleware.cs | 0 .../Models/IAuditable.cs | 0 .../Models/IdmtAuditLog.cs | 0 .../Models/IdmtRole.cs | 0 .../Models/IdmtTenantInfo.cs | 0 .../Models/IdmtUser.cs | 0 .../Models/RevokedToken.cs | 0 .../Models/TenantAccess.cs | 0 .../Persistence/IdmtDbContext.cs | 0 .../Persistence/IdmtTenantStoreDbContext.cs | 0 .../Services/Base64Service.cs | 0 .../Services/CurrentUserService.cs | 0 .../Services/ICurrentUserService.cs | 0 .../Services/ITenantAccessService.cs | 0 .../Services/ITenantOperationService.cs | 0 .../Services/ITokenRevocationService.cs | 0 .../Services/IdmtEmailSender.cs | 0 .../Services/IdmtEmailSenderStartupCheck.cs | 0 .../Services/IdmtLinkGenerator.cs | 0 .../IdmtUserClaimsPrincipalFactory.cs | 0 .../Services/PiiMasker.cs | 0 .../Services/TenantAccessService.cs | 0 .../Services/TenantOperationService.cs | 0 .../Services/TokenRevocationCleanupService.cs | 0 .../Services/TokenRevocationService.cs | 0 .../ConfirmEmailRequestValidator.cs | 0 .../CreateTenantRequestValidator.cs | 0 .../DiscoverTenantsRequestValidator.cs | 0 .../ForgotPasswordRequestValidator.cs | 0 .../Validation/LoginRequestValidator.cs | 0 .../RefreshTokenRequestValidator.cs | 0 .../RegisterUserRequestValidator.cs | 0 ...ResendConfirmationEmailRequestValidator.cs | 0 .../ResetPasswordRequestValidator.cs | 0 .../UpdateUserInfoRequestValidator.cs | 0 .../Validation/ValidationHelper.cs | 0 .../Validation/Validators.cs | 0 src/Idmt.slnx => Idmt.slnx | 0 README.md | 25 +++--- .../Idmt.BasicSample/Idmt.BasicSample.csproj | 0 .../Idmt.BasicSample/Program.cs | 0 .../Properties/launchSettings.json | 0 .../Idmt.BasicSample/SeedTestUser.cs | 0 .../appsettings.Development.json | 0 .../Idmt.BasicSample/appsettings.json | 0 .../Idmt.BasicSample/wwwroot/README.md | 0 .../Idmt.BasicSample/wwwroot/css/styles.css | 0 .../Idmt.BasicSample/wwwroot/index.html | 0 .../Idmt.BasicSample/wwwroot/js/api-client.js | 0 .../AdminIntegrationTests.cs | 85 ++++++++++++++++++ .../AuthIntegrationTests.cs | 37 ++++++++ .../BaseIntegrationTest.cs | 0 .../HttpResponseExtensions.cs | 0 .../Idmt.BasicSample.Tests.csproj | 0 .../Idmt.BasicSample.Tests/IdmtApiFactory.cs | 0 .../ManageIntegrationTests.cs | 0 .../MultiTenancyIntegrationTests.cs | 22 +++++ .../IdmtOptionsValidatorTests.cs | 18 ++-- .../Configuration/RateLimitingOptionsTests.cs | 0 .../Admin/CreateTenantHandlerTests.cs | 0 .../Admin/DeleteTenantHandlerTests.cs | 0 .../Admin/GetAllTenantsHandlerTests.cs | 0 .../Admin/GetUserTenantsHandlerTests.cs | 0 .../Admin/GrantTenantAccessHandlerTests.cs | 0 .../Admin/RevokeTenantAccessHandlerTests.cs | 3 + .../Features/Auth/ConfirmEmailHandlerTests.cs | 0 .../Auth/DiscoverTenantsHandlerTests.cs | 0 .../Auth/ForgotPasswordHandlerTests.cs | 0 .../Features/Auth/LoginHandlerTests.cs | 0 .../Features/Auth/LogoutHandlerTests.cs | 90 ++++++++++++++----- .../Features/Auth/RefreshTokenHandlerTests.cs | 0 .../ResendConfirmationEmailHandlerTests.cs | 0 .../Auth/ResetPasswordHandlerTests.cs | 0 .../Features/Auth/TokenLoginHandlerTests.cs | 0 .../Features/Health/BasicHealthCheckTests.cs | 0 .../Manage/GetUserInfoHandlerTests.cs | 0 .../Features/Manage/RegisterHandlerTests.cs | 0 .../Features/Manage/UnregisterHandlerTests.cs | 0 .../Features/Manage/UpdateUserHandlerTests.cs | 0 .../Manage/UpdateUserInfoHandlerTests.cs | 6 ++ .../Idmt.UnitTests/Idmt.UnitTests.csproj | 0 .../Middleware/CurrentUserMiddlewareTests.cs | 0 ...alidateBearerTokenTenantMiddlewareTests.cs | 0 .../Models/IdmtTenantInfoTests.cs | 0 .../Services/CoreServicesTests.cs | 0 .../Services/IdmtLinkGeneratorTests.cs | 0 .../IdmtUserClaimsPrincipalFactoryTests.cs | 0 .../Services/TenantAccessServiceTests.cs | 0 .../Services/TenantOperationServiceTests.cs | 0 .../TokenRevocationCleanupServiceTests.cs | 0 .../Services/TokenRevocationServiceTests.cs | 0 .../Validation/FluentValidatorTests.cs | 0 .../Validation/ValidatorsTests.cs | 0 129 files changed, 312 insertions(+), 73 deletions(-) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtEndpointNames.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtOptions.cs (99%) rename {src/Idmt.Plugin => Idmt.Plugin}/Configuration/IdmtOptionsValidator.cs (88%) rename {src/Idmt.Plugin => Idmt.Plugin}/Constants/AuditAction.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Constants/IdmtClaimTypes.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Errors/IdmtErrors.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Extensions/ApplicationBuilderExtensions.cs (95%) rename {src/Idmt.Plugin => Idmt.Plugin}/Extensions/ServiceCollectionExtensions.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/AdminModels.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/CreateTenant.cs (95%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/DeleteTenant.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GetAllTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GetUserTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/GrantTenantAccess.cs (97%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Admin/RevokeTenantAccess.cs (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/AdminEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ConfirmEmail.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/DiscoverTenants.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ForgotPassword.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/Login.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/Logout.cs (69%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/RefreshToken.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ResendConfirmationEmail.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Auth/ResetPassword.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/AuthEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Health/BasicHealthCheck.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/GetUserInfo.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/RegisterUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UnregisterUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UpdateUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/Manage/UpdateUserInfo.cs (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Features/ManageEndpoints.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Idmt.Plugin.csproj (94%) rename {src/Idmt.Plugin => Idmt.Plugin}/Middleware/CurrentUserMiddleware.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Middleware/ValidateBearerTokenTenantMiddleware.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IAuditable.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtAuditLog.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtRole.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtTenantInfo.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/IdmtUser.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/RevokedToken.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Models/TenantAccess.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Persistence/IdmtDbContext.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Persistence/IdmtTenantStoreDbContext.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/Base64Service.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/CurrentUserService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ICurrentUserService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITenantAccessService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITenantOperationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/ITokenRevocationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtEmailSender.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtEmailSenderStartupCheck.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtLinkGenerator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/IdmtUserClaimsPrincipalFactory.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/PiiMasker.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TenantAccessService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TenantOperationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TokenRevocationCleanupService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Services/TokenRevocationService.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ConfirmEmailRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/CreateTenantRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/DiscoverTenantsRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ForgotPasswordRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/LoginRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/RefreshTokenRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/RegisterUserRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ResendConfirmationEmailRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ResetPasswordRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/UpdateUserInfoRequestValidator.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/ValidationHelper.cs (100%) rename {src/Idmt.Plugin => Idmt.Plugin}/Validation/Validators.cs (100%) rename src/Idmt.slnx => Idmt.slnx (100%) rename {src/samples => samples}/Idmt.BasicSample/Idmt.BasicSample.csproj (100%) rename {src/samples => samples}/Idmt.BasicSample/Program.cs (100%) rename {src/samples => samples}/Idmt.BasicSample/Properties/launchSettings.json (100%) rename {src/samples => samples}/Idmt.BasicSample/SeedTestUser.cs (100%) rename {src/samples => samples}/Idmt.BasicSample/appsettings.Development.json (100%) rename {src/samples => samples}/Idmt.BasicSample/appsettings.json (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/README.md (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/css/styles.css (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/index.html (100%) rename {src/samples => samples}/Idmt.BasicSample/wwwroot/js/api-client.js (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/AdminIntegrationTests.cs (83%) rename {src/tests => tests}/Idmt.BasicSample.Tests/AuthIntegrationTests.cs (93%) rename {src/tests => tests}/Idmt.BasicSample.Tests/BaseIntegrationTest.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/HttpResponseExtensions.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/IdmtApiFactory.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/ManageIntegrationTests.cs (100%) rename {src/tests => tests}/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs (93%) rename {src/tests => tests}/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs (94%) rename {src/tests => tests}/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs (95%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs (77%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs (97%) rename {src/tests => tests}/Idmt.UnitTests/Idmt.UnitTests.csproj (100%) rename {src/tests => tests}/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/CoreServicesTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TenantAccessServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TenantOperationServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Validation/FluentValidatorTests.cs (100%) rename {src/tests => tests}/Idmt.UnitTests/Validation/ValidatorsTests.cs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f1c84d..9dc4b46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,22 +20,22 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/Idmt.slnx + run: dotnet restore Idmt.slnx - name: Format check - run: dotnet format src/Idmt.slnx --verify-no-changes --verbosity diagnostic + run: dotnet format Idmt.slnx --verify-no-changes --verbosity diagnostic - name: Build - run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true + run: dotnet build Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true - name: Run analyzers - run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true + run: dotnet build Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true - name: Test - run: dotnet test src/Idmt.slnx --no-build --configuration Release --verbosity normal + run: dotnet test Idmt.slnx --no-build --configuration Release --verbosity normal - name: Pack - run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . + run: dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . - name: Upload NuGet package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 46e910b..617fc85 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,20 +16,20 @@ jobs: dotnet-version: 10.0.x - name: Restore dependencies - run: dotnet restore src/Idmt.slnx + run: dotnet restore Idmt.slnx - name: Build - run: dotnet build src/Idmt.slnx --no-restore --configuration Release + run: dotnet build Idmt.slnx --no-restore --configuration Release - name: Test - run: dotnet test src/Idmt.slnx --no-build --configuration Release + run: dotnet test Idmt.slnx --no-build --configuration Release - name: Set Version id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Pack - run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . -p:PackageVersion=${{ env.VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg + run: dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output . -p:PackageVersion=${{ env.VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg - name: Push to NuGet run: dotnet nuget push *.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} diff --git a/src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs b/Idmt.Plugin/Configuration/IdmtEndpointNames.cs similarity index 100% rename from src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs rename to Idmt.Plugin/Configuration/IdmtEndpointNames.cs diff --git a/src/Idmt.Plugin/Configuration/IdmtOptions.cs b/Idmt.Plugin/Configuration/IdmtOptions.cs similarity index 99% rename from src/Idmt.Plugin/Configuration/IdmtOptions.cs rename to Idmt.Plugin/Configuration/IdmtOptions.cs index d50b85e..58771c6 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -314,7 +314,7 @@ public class RateLimitingOptions public int PermitLimit { get; set; } = 10; /// - /// Duration of the sliding window in seconds. Default: 60. + /// Duration of the fixed window in seconds. Default: 60. /// public int WindowInSeconds { get; set; } = 60; } \ No newline at end of file diff --git a/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs b/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs similarity index 88% rename from src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs rename to Idmt.Plugin/Configuration/IdmtOptionsValidator.cs index 6974467..1b62412 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs +++ b/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs @@ -34,13 +34,14 @@ private static void ValidateApplicationOptions(ApplicationOptions application, L "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)) + // Rule 2: ClientUrl is always required because password reset links always use + // client form URLs (GeneratePasswordResetLink), regardless of EmailConfirmationMode. + if (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)}."); + $"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ClientUrl)} must not be null or empty. " + + "It is required for password reset links and for email confirmation when " + + $"{nameof(ApplicationOptions.EmailConfirmationMode)} is {nameof(EmailConfirmationMode.ClientForm)}."); } // Rule 3: When ClientUrl is set, the client-side form paths must also be configured. diff --git a/src/Idmt.Plugin/Constants/AuditAction.cs b/Idmt.Plugin/Constants/AuditAction.cs similarity index 100% rename from src/Idmt.Plugin/Constants/AuditAction.cs rename to Idmt.Plugin/Constants/AuditAction.cs diff --git a/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs b/Idmt.Plugin/Constants/IdmtClaimTypes.cs similarity index 100% rename from src/Idmt.Plugin/Constants/IdmtClaimTypes.cs rename to Idmt.Plugin/Constants/IdmtClaimTypes.cs diff --git a/src/Idmt.Plugin/Errors/IdmtErrors.cs b/Idmt.Plugin/Errors/IdmtErrors.cs similarity index 100% rename from src/Idmt.Plugin/Errors/IdmtErrors.cs rename to Idmt.Plugin/Errors/IdmtErrors.cs diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs similarity index 95% rename from src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs rename to Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index 7a2ba63..a26b176 100644 --- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -173,9 +173,15 @@ private static async Task SeedDefaultDataAsync(IServiceProvider services) { var options = services.GetRequiredService>(); var createTenantHandler = services.GetRequiredService(); - await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( + var result = await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( MultiTenantOptions.DefaultTenantIdentifier, options.Value.MultiTenant.DefaultTenantName)); + + if (result.IsError && result.FirstError.Code != "Tenant.AlreadyExists") + { + throw new InvalidOperationException( + $"Failed to seed default tenant '{MultiTenantOptions.DefaultTenantIdentifier}': {result.FirstError.Description}"); + } } private static void VerifyUserStoreSupportsEmail(IApplicationBuilder app) diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs rename to Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs diff --git a/src/Idmt.Plugin/Features/Admin/AdminModels.cs b/Idmt.Plugin/Features/Admin/AdminModels.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/AdminModels.cs rename to Idmt.Plugin/Features/Admin/AdminModels.cs diff --git a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs b/Idmt.Plugin/Features/Admin/CreateTenant.cs similarity index 95% rename from src/Idmt.Plugin/Features/Admin/CreateTenant.cs rename to Idmt.Plugin/Features/Admin/CreateTenant.cs index 4b73a52..60f34c7 100644 --- a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -151,7 +151,8 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), }; } - return TypedResults.Created($"/admin/tenants/{response.Value.Identifier}", response.Value); + var apiPrefix = context.RequestServices.GetRequiredService>().Value.Application.ApiPrefix ?? string.Empty; + return TypedResults.Created($"{apiPrefix}/admin/tenants/{response.Value.Identifier}", response.Value); }) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); diff --git a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs b/Idmt.Plugin/Features/Admin/DeleteTenant.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/DeleteTenant.cs rename to Idmt.Plugin/Features/Admin/DeleteTenant.cs diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/Idmt.Plugin/Features/Admin/GetAllTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/GetAllTenants.cs rename to Idmt.Plugin/Features/Admin/GetAllTenants.cs diff --git a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs b/Idmt.Plugin/Features/Admin/GetUserTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Admin/GetUserTenants.cs rename to Idmt.Plugin/Features/Admin/GetUserTenants.cs diff --git a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs similarity index 97% rename from src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs rename to Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 7c6e507..8cffe29 100644 --- a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -120,8 +120,9 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti Email = user.Email, EmailConfirmed = user.EmailConfirmed, PasswordHash = user.PasswordHash, - SecurityStamp = user.SecurityStamp, - ConcurrencyStamp = user.ConcurrencyStamp, + // SecurityStamp and ConcurrencyStamp intentionally omitted — + // UserManager.CreateAsync generates fresh values so that session + // invalidation in one tenant does not affect the other. PhoneNumber = user.PhoneNumber, PhoneNumberConfirmed = user.PhoneNumberConfirmed, TwoFactorEnabled = user.TwoFactorEnabled, diff --git a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs similarity index 94% rename from src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs rename to Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 839f04e..1cd5acd 100644 --- a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -31,6 +31,7 @@ internal sealed class RevokeTenantAccessHandler( IdmtDbContext dbContext, IMultiTenantStore tenantStore, ITenantOperationService tenantOps, + ITokenRevocationService tokenRevocationService, ILogger logger) : IRevokeTenantAccessHandler { public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) @@ -61,6 +62,9 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti tenantAccess.IsActive = false; dbContext.TenantAccess.Update(tenantAccess); await dbContext.SaveChangesAsync(cancellationToken); + + // Revoke any active bearer tokens so the user cannot refresh after access is removed + await tokenRevocationService.RevokeUserTokensAsync(userId, targetTenant.Id!, cancellationToken); } catch (Exception ex) { diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/Idmt.Plugin/Features/AdminEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/AdminEndpoints.cs rename to Idmt.Plugin/Features/AdminEndpoints.cs diff --git a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/Idmt.Plugin/Features/Auth/ConfirmEmail.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs rename to Idmt.Plugin/Features/Auth/ConfirmEmail.cs diff --git a/src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs b/Idmt.Plugin/Features/Auth/DiscoverTenants.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/DiscoverTenants.cs rename to Idmt.Plugin/Features/Auth/DiscoverTenants.cs diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/Idmt.Plugin/Features/Auth/ForgotPassword.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ForgotPassword.cs rename to Idmt.Plugin/Features/Auth/ForgotPassword.cs diff --git a/src/Idmt.Plugin/Features/Auth/Login.cs b/Idmt.Plugin/Features/Auth/Login.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/Login.cs rename to Idmt.Plugin/Features/Auth/Login.cs diff --git a/src/Idmt.Plugin/Features/Auth/Logout.cs b/Idmt.Plugin/Features/Auth/Logout.cs similarity index 69% rename from src/Idmt.Plugin/Features/Auth/Logout.cs rename to Idmt.Plugin/Features/Auth/Logout.cs index 28da8bb..400cda3 100644 --- a/src/Idmt.Plugin/Features/Auth/Logout.cs +++ b/Idmt.Plugin/Features/Auth/Logout.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -27,6 +28,7 @@ internal sealed class LogoutHandler( SignInManager signInManager, ICurrentUserService currentUserService, IMultiTenantContextAccessor tenantContextAccessor, + IMultiTenantStore tenantStore, IOptions idmtOptions, ITokenRevocationService tokenRevocationService) : ILogoutHandler @@ -48,21 +50,33 @@ public async Task> HandleAsync(CancellationToken cancellationTo 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. + // (e.g. header or route strategies at logout time). Extract the tenant + // identifier from the bearer principal's claims and resolve the tenant + // via the store so revocation can still proceed. 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 ?? ""); + if (tenantIdentifierFromClaim is not null) + { + var resolvedTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifierFromClaim); + if (resolvedTenant?.Id is not null) + { + await tokenRevocationService.RevokeUserTokensAsync(userId, resolvedTenant.Id, cancellationToken); + } + else + { + logger.LogWarning( + "Token revocation skipped for user {UserId}: tenant identifier {TenantIdentifier} from bearer claims could not be resolved.", + userId, tenantIdentifierFromClaim); + } + } + else + { + logger.LogWarning( + "Token revocation skipped for user {UserId}: no tenant context resolved and no tenant claim present in bearer token.", + userId); + } } } diff --git a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs b/Idmt.Plugin/Features/Auth/RefreshToken.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/RefreshToken.cs rename to Idmt.Plugin/Features/Auth/RefreshToken.cs diff --git a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs b/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs rename to Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs diff --git a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs b/Idmt.Plugin/Features/Auth/ResetPassword.cs similarity index 100% rename from src/Idmt.Plugin/Features/Auth/ResetPassword.cs rename to Idmt.Plugin/Features/Auth/ResetPassword.cs diff --git a/src/Idmt.Plugin/Features/AuthEndpoints.cs b/Idmt.Plugin/Features/AuthEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/AuthEndpoints.cs rename to Idmt.Plugin/Features/AuthEndpoints.cs diff --git a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs b/Idmt.Plugin/Features/Health/BasicHealthCheck.cs similarity index 100% rename from src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs rename to Idmt.Plugin/Features/Health/BasicHealthCheck.cs diff --git a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/Idmt.Plugin/Features/Manage/GetUserInfo.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/GetUserInfo.cs rename to Idmt.Plugin/Features/Manage/GetUserInfo.cs diff --git a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs b/Idmt.Plugin/Features/Manage/RegisterUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/RegisterUser.cs rename to Idmt.Plugin/Features/Manage/RegisterUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs b/Idmt.Plugin/Features/Manage/UnregisterUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/UnregisterUser.cs rename to Idmt.Plugin/Features/Manage/UnregisterUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUser.cs b/Idmt.Plugin/Features/Manage/UpdateUser.cs similarity index 100% rename from src/Idmt.Plugin/Features/Manage/UpdateUser.cs rename to Idmt.Plugin/Features/Manage/UpdateUser.cs diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs similarity index 94% rename from src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs rename to Idmt.Plugin/Features/Manage/UpdateUserInfo.cs index 28b45b3..ed1aeaf 100644 --- a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs +++ b/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs @@ -35,6 +35,8 @@ internal sealed class UpdateUserInfoHandler( IdmtDbContext dbContext, IIdmtLinkGenerator linkGenerator, IEmailSender emailSender, + ICurrentUserService currentUserService, + ITokenRevocationService tokenRevocationService, ILogger logger) : IUpdateUserInfoHandler { public async Task> HandleAsync( @@ -101,6 +103,12 @@ public async Task> HandleAsync( var confirmLink = linkGenerator.GenerateConfirmEmailLink(request.NewEmail, confirmToken); await emailSender.SendConfirmationLinkAsync(appUser, request.NewEmail, confirmLink); + // Revoke existing bearer tokens so old refresh tokens cannot be used + if (currentUserService.UserId is { } uid && currentUserService.TenantId is { } tid) + { + await tokenRevocationService.RevokeUserTokensAsync(uid, tid, cancellationToken); + } + logger.LogInformation("Email changed for user. Confirmation email dispatched to new address."); // hasChanges intentionally not set here: ChangeEmailAsync already persisted the // email change. The flag only controls the final UpdateAsync for other field diff --git a/src/Idmt.Plugin/Features/ManageEndpoints.cs b/Idmt.Plugin/Features/ManageEndpoints.cs similarity index 100% rename from src/Idmt.Plugin/Features/ManageEndpoints.cs rename to Idmt.Plugin/Features/ManageEndpoints.cs diff --git a/src/Idmt.Plugin/Idmt.Plugin.csproj b/Idmt.Plugin/Idmt.Plugin.csproj similarity index 94% rename from src/Idmt.Plugin/Idmt.Plugin.csproj rename to Idmt.Plugin/Idmt.Plugin.csproj index fc5031e..8ffe614 100644 --- a/src/Idmt.Plugin/Idmt.Plugin.csproj +++ b/Idmt.Plugin/Idmt.Plugin.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs b/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs similarity index 100% rename from src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs rename to Idmt.Plugin/Middleware/CurrentUserMiddleware.cs diff --git a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs b/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs similarity index 100% rename from src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs rename to Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs diff --git a/src/Idmt.Plugin/Models/IAuditable.cs b/Idmt.Plugin/Models/IAuditable.cs similarity index 100% rename from src/Idmt.Plugin/Models/IAuditable.cs rename to Idmt.Plugin/Models/IAuditable.cs diff --git a/src/Idmt.Plugin/Models/IdmtAuditLog.cs b/Idmt.Plugin/Models/IdmtAuditLog.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtAuditLog.cs rename to Idmt.Plugin/Models/IdmtAuditLog.cs diff --git a/src/Idmt.Plugin/Models/IdmtRole.cs b/Idmt.Plugin/Models/IdmtRole.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtRole.cs rename to Idmt.Plugin/Models/IdmtRole.cs diff --git a/src/Idmt.Plugin/Models/IdmtTenantInfo.cs b/Idmt.Plugin/Models/IdmtTenantInfo.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtTenantInfo.cs rename to Idmt.Plugin/Models/IdmtTenantInfo.cs diff --git a/src/Idmt.Plugin/Models/IdmtUser.cs b/Idmt.Plugin/Models/IdmtUser.cs similarity index 100% rename from src/Idmt.Plugin/Models/IdmtUser.cs rename to Idmt.Plugin/Models/IdmtUser.cs diff --git a/src/Idmt.Plugin/Models/RevokedToken.cs b/Idmt.Plugin/Models/RevokedToken.cs similarity index 100% rename from src/Idmt.Plugin/Models/RevokedToken.cs rename to Idmt.Plugin/Models/RevokedToken.cs diff --git a/src/Idmt.Plugin/Models/TenantAccess.cs b/Idmt.Plugin/Models/TenantAccess.cs similarity index 100% rename from src/Idmt.Plugin/Models/TenantAccess.cs rename to Idmt.Plugin/Models/TenantAccess.cs diff --git a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs b/Idmt.Plugin/Persistence/IdmtDbContext.cs similarity index 100% rename from src/Idmt.Plugin/Persistence/IdmtDbContext.cs rename to Idmt.Plugin/Persistence/IdmtDbContext.cs diff --git a/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs b/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs similarity index 100% rename from src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs rename to Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs diff --git a/src/Idmt.Plugin/Services/Base64Service.cs b/Idmt.Plugin/Services/Base64Service.cs similarity index 100% rename from src/Idmt.Plugin/Services/Base64Service.cs rename to Idmt.Plugin/Services/Base64Service.cs diff --git a/src/Idmt.Plugin/Services/CurrentUserService.cs b/Idmt.Plugin/Services/CurrentUserService.cs similarity index 100% rename from src/Idmt.Plugin/Services/CurrentUserService.cs rename to Idmt.Plugin/Services/CurrentUserService.cs diff --git a/src/Idmt.Plugin/Services/ICurrentUserService.cs b/Idmt.Plugin/Services/ICurrentUserService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ICurrentUserService.cs rename to Idmt.Plugin/Services/ICurrentUserService.cs diff --git a/src/Idmt.Plugin/Services/ITenantAccessService.cs b/Idmt.Plugin/Services/ITenantAccessService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITenantAccessService.cs rename to Idmt.Plugin/Services/ITenantAccessService.cs diff --git a/src/Idmt.Plugin/Services/ITenantOperationService.cs b/Idmt.Plugin/Services/ITenantOperationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITenantOperationService.cs rename to Idmt.Plugin/Services/ITenantOperationService.cs diff --git a/src/Idmt.Plugin/Services/ITokenRevocationService.cs b/Idmt.Plugin/Services/ITokenRevocationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/ITokenRevocationService.cs rename to Idmt.Plugin/Services/ITokenRevocationService.cs diff --git a/src/Idmt.Plugin/Services/IdmtEmailSender.cs b/Idmt.Plugin/Services/IdmtEmailSender.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtEmailSender.cs rename to Idmt.Plugin/Services/IdmtEmailSender.cs diff --git a/src/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs b/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs rename to Idmt.Plugin/Services/IdmtEmailSenderStartupCheck.cs diff --git a/src/Idmt.Plugin/Services/IdmtLinkGenerator.cs b/Idmt.Plugin/Services/IdmtLinkGenerator.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtLinkGenerator.cs rename to Idmt.Plugin/Services/IdmtLinkGenerator.cs diff --git a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs b/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs similarity index 100% rename from src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs rename to Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs diff --git a/src/Idmt.Plugin/Services/PiiMasker.cs b/Idmt.Plugin/Services/PiiMasker.cs similarity index 100% rename from src/Idmt.Plugin/Services/PiiMasker.cs rename to Idmt.Plugin/Services/PiiMasker.cs diff --git a/src/Idmt.Plugin/Services/TenantAccessService.cs b/Idmt.Plugin/Services/TenantAccessService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TenantAccessService.cs rename to Idmt.Plugin/Services/TenantAccessService.cs diff --git a/src/Idmt.Plugin/Services/TenantOperationService.cs b/Idmt.Plugin/Services/TenantOperationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TenantOperationService.cs rename to Idmt.Plugin/Services/TenantOperationService.cs diff --git a/src/Idmt.Plugin/Services/TokenRevocationCleanupService.cs b/Idmt.Plugin/Services/TokenRevocationCleanupService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TokenRevocationCleanupService.cs rename to Idmt.Plugin/Services/TokenRevocationCleanupService.cs diff --git a/src/Idmt.Plugin/Services/TokenRevocationService.cs b/Idmt.Plugin/Services/TokenRevocationService.cs similarity index 100% rename from src/Idmt.Plugin/Services/TokenRevocationService.cs rename to Idmt.Plugin/Services/TokenRevocationService.cs diff --git a/src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs b/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs rename to Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs b/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs rename to Idmt.Plugin/Validation/CreateTenantRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs b/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs rename to Idmt.Plugin/Validation/DiscoverTenantsRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs b/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs rename to Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/LoginRequestValidator.cs b/Idmt.Plugin/Validation/LoginRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/LoginRequestValidator.cs rename to Idmt.Plugin/Validation/LoginRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs b/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs rename to Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs b/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs rename to Idmt.Plugin/Validation/RegisterUserRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs b/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs rename to Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs b/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs rename to Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs b/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs similarity index 100% rename from src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs rename to Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs diff --git a/src/Idmt.Plugin/Validation/ValidationHelper.cs b/Idmt.Plugin/Validation/ValidationHelper.cs similarity index 100% rename from src/Idmt.Plugin/Validation/ValidationHelper.cs rename to Idmt.Plugin/Validation/ValidationHelper.cs diff --git a/src/Idmt.Plugin/Validation/Validators.cs b/Idmt.Plugin/Validation/Validators.cs similarity index 100% rename from src/Idmt.Plugin/Validation/Validators.cs rename to Idmt.Plugin/Validation/Validators.cs diff --git a/src/Idmt.slnx b/Idmt.slnx similarity index 100% rename from src/Idmt.slnx rename to Idmt.slnx diff --git a/README.md b/README.md index 827494e..02f71de 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Rate-limited. All endpoints are public except `/auth/logout`. | 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. | +| POST | /auth/discover-tenants | - | Discover tenants associated with an email address. Accepts `{ email }` and returns a tenant list. | Login requests accept `email` or `username`, `password`, `rememberMe`, and optionally `twoFactorCode` / `twoFactorRecoveryCode`. @@ -143,11 +144,11 @@ All endpoints require authentication. | 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. | +| GET | /manage/info | Default (authenticated) | Get the current user's profile. | +| PUT | /manage/info | Default (authenticated) | Update profile, email, or password. | +| POST | /manage/users | RequireTenantManager | Register a new user (invite flow — sends password-setup email). | +| PUT | /manage/users/{userId:guid} | RequireTenantManager | Activate or deactivate a user. | +| DELETE | /manage/users/{userId:guid} | RequireTenantManager | Delete a user. | ### Administration — `/admin` @@ -156,11 +157,11 @@ All endpoints require the `RequireSysUser` policy (`SysAdmin` or `SysSupport` ro | Method | Path | Description | |--------|------|-------------| | POST | /admin/tenants | Create a new tenant. | -| DELETE | /admin/tenants/{identifier} | Soft-delete a tenant. | +| DELETE | /admin/tenants/{tenantIdentifier} | 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. | +| GET | /admin/users/{userId:guid}/tenants | List tenants accessible by a user. | +| POST | /admin/users/{userId:guid}/tenants/{tenantIdentifier} | Grant a user access to a tenant. | +| DELETE | /admin/users/{userId:guid}/tenants/{tenantIdentifier} | Revoke a user's access to a tenant. | ### Health — `/healthz` @@ -175,6 +176,8 @@ Requires `RequireSysUser`. Returns database connectivity status via ASP.NET Core | `RequireSysAdmin` | SysAdmin | | `RequireSysUser` | SysAdmin, SysSupport | | `RequireTenantManager` | SysAdmin, SysSupport, TenantAdmin | +| `CookieOnly` | — (requires cookie authentication scheme) | +| `BearerOnly` | — (requires bearer authentication scheme) | Default roles seeded at startup: `SysAdmin`, `SysSupport`, `TenantAdmin`. Add custom roles via `Identity.ExtraRoles` in configuration. @@ -233,11 +236,11 @@ builder.Services.AddIdmt( { options.Application.ApiPrefix = "/api/v2"; }, - customizeAuth: auth => + customizeAuthentication: auth => { // Add additional authentication schemes }, - customizeAuthz: authz => + customizeAuthorization: authz => { // Add additional authorization policies } diff --git a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj b/samples/Idmt.BasicSample/Idmt.BasicSample.csproj similarity index 100% rename from src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj rename to samples/Idmt.BasicSample/Idmt.BasicSample.csproj diff --git a/src/samples/Idmt.BasicSample/Program.cs b/samples/Idmt.BasicSample/Program.cs similarity index 100% rename from src/samples/Idmt.BasicSample/Program.cs rename to samples/Idmt.BasicSample/Program.cs diff --git a/src/samples/Idmt.BasicSample/Properties/launchSettings.json b/samples/Idmt.BasicSample/Properties/launchSettings.json similarity index 100% rename from src/samples/Idmt.BasicSample/Properties/launchSettings.json rename to samples/Idmt.BasicSample/Properties/launchSettings.json diff --git a/src/samples/Idmt.BasicSample/SeedTestUser.cs b/samples/Idmt.BasicSample/SeedTestUser.cs similarity index 100% rename from src/samples/Idmt.BasicSample/SeedTestUser.cs rename to samples/Idmt.BasicSample/SeedTestUser.cs diff --git a/src/samples/Idmt.BasicSample/appsettings.Development.json b/samples/Idmt.BasicSample/appsettings.Development.json similarity index 100% rename from src/samples/Idmt.BasicSample/appsettings.Development.json rename to samples/Idmt.BasicSample/appsettings.Development.json diff --git a/src/samples/Idmt.BasicSample/appsettings.json b/samples/Idmt.BasicSample/appsettings.json similarity index 100% rename from src/samples/Idmt.BasicSample/appsettings.json rename to samples/Idmt.BasicSample/appsettings.json diff --git a/src/samples/Idmt.BasicSample/wwwroot/README.md b/samples/Idmt.BasicSample/wwwroot/README.md similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/README.md rename to samples/Idmt.BasicSample/wwwroot/README.md diff --git a/src/samples/Idmt.BasicSample/wwwroot/css/styles.css b/samples/Idmt.BasicSample/wwwroot/css/styles.css similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/css/styles.css rename to samples/Idmt.BasicSample/wwwroot/css/styles.css diff --git a/src/samples/Idmt.BasicSample/wwwroot/index.html b/samples/Idmt.BasicSample/wwwroot/index.html similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/index.html rename to samples/Idmt.BasicSample/wwwroot/index.html diff --git a/src/samples/Idmt.BasicSample/wwwroot/js/api-client.js b/samples/Idmt.BasicSample/wwwroot/js/api-client.js similarity index 100% rename from src/samples/Idmt.BasicSample/wwwroot/js/api-client.js rename to samples/Idmt.BasicSample/wwwroot/js/api-client.js diff --git a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs similarity index 83% rename from src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 8aef208..815bb6d 100644 --- a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -1,8 +1,12 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using Idmt.Plugin.Features.Admin; +using Idmt.Plugin.Features.Auth; using Idmt.Plugin.Features.Manage; using Idmt.Plugin.Models; +using Idmt.Plugin.Persistence; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Idmt.BasicSample.Tests; @@ -471,6 +475,87 @@ public async Task DeleteTenant_ReturnsForbidden_WhenDeletingDefaultTenant() #endregion + #region Revoke Tenant Access Security Tests + + [Fact] + public async Task RevokeTenantAccess_RefreshToken_IsRejectedAfterRevocation() + { + // Clean up any revoked tokens from other tests + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.RevokedTokens.ExecuteDeleteAsync(); + } + + var sysClient = await CreateAuthenticatedClientAsync(); + + // Create a second tenant + var secondTenantIdentifier = $"revoke-rt-{Guid.NewGuid():N}"; + var createTenantResponse = await sysClient.PostAsJsonAsync("/admin/tenants", new + { + Identifier = secondTenantIdentifier, + Name = "Revoke RT Tenant" + }); + await createTenantResponse.AssertSuccess(); + + // Register a user in the system tenant + var email = $"revoke-rt-{Guid.NewGuid():N}@example.com"; + var password = "RevokeRT1!"; + var (userId, _) = await RegisterAndSetPasswordAsync(sysClient, password, email: email, username: $"revokert{Guid.NewGuid():N}"); + + // Grant user access to the second tenant + var grantResponse = await sysClient.PostAsJsonAsync( + $"/admin/users/{userId}/tenants/{secondTenantIdentifier}", + new { ExpiresAt = (DateTime?)null }); + await grantResponse.AssertSuccess(); + + // Login as that user to the second tenant + var tenantClient = Factory.CreateClientWithTenant(secondTenantIdentifier); + var loginResponse = await tenantClient.PostAsJsonAsync("/auth/token", new + { + Email = email, + Password = password + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + tenantClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + // Verify refresh token works before revocation + var refreshBefore = await tenantClient.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens.RefreshToken!)); + await refreshBefore.AssertSuccess(); + var refreshedTokens = await refreshBefore.Content.ReadFromJsonAsync(); + + // Revoke access to the second tenant + var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{secondTenantIdentifier}"); + Assert.Equal(HttpStatusCode.NoContent, revokeResponse.StatusCode); + + // Assert refresh token for that tenant now fails + var refreshAfter = await tenantClient.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(refreshedTokens!.RefreshToken!)); + Assert.False(refreshAfter.IsSuccessStatusCode); + } + + #endregion + + #region Default Tenant Tests + + [Fact] + public async Task DefaultTenant_ExistsAfterStartup() + { + var sysClient = await CreateAuthenticatedClientAsync(); + + // The GET /admin/tenants endpoint filters out the system tenant, + // so we verify the system tenant exists by checking the tenant store directly + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService>(); + var tenant = await store.GetByIdentifierAsync(IdmtApiFactory.DefaultTenantIdentifier); + + Assert.NotNull(tenant); + Assert.Equal(IdmtApiFactory.DefaultTenantIdentifier, tenant!.Identifier); + } + + #endregion + #region Grant Tenant Access Validation Tests [Fact] diff --git a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs similarity index 93% rename from src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index 0708dd8..b1cd248 100644 --- a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -344,6 +344,43 @@ public async Task Logout_WithoutAuthentication_Returns401() Assert.Contains(logoutResponse.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Found }); } + [Fact] + public async Task Logout_Bearer_RefreshToken_IsRejectedAfterLogout() + { + // Clean up any revoked tokens from other tests + using (var scope = Factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.RevokedTokens.ExecuteDeleteAsync(); + } + + var client = Factory.CreateClientWithTenant(); + + // Login to get access + refresh tokens + var loginResponse = await client.PostAsJsonAsync("/auth/token", new + { + Email = IdmtApiFactory.SysAdminEmail, + Password = IdmtApiFactory.SysAdminPassword + }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); + + // Verify refresh works before logout + var refreshBeforeLogout = await client.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens.RefreshToken!)); + await refreshBeforeLogout.AssertSuccess(); + var refreshedTokens = await refreshBeforeLogout.Content.ReadFromJsonAsync(); + + // Logout with Bearer token + var logoutResponse = await client.PostAsync("/auth/logout", content: null); + Assert.Equal(HttpStatusCode.NoContent, logoutResponse.StatusCode); + + // Assert refresh now fails after logout + var refreshAfterLogout = await client.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(refreshedTokens!.RefreshToken!)); + Assert.False(refreshAfterLogout.IsSuccessStatusCode); + } + #endregion #region Confirm Email Tests diff --git a/src/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs b/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs rename to tests/Idmt.BasicSample.Tests/BaseIntegrationTest.cs diff --git a/src/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs b/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs rename to tests/Idmt.BasicSample.Tests/HttpResponseExtensions.cs diff --git a/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj b/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj rename to tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj diff --git a/src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs rename to tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs diff --git a/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs similarity index 100% rename from src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs diff --git a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs similarity index 93% rename from src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs rename to tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index ca9afa3..2a00b28 100644 --- a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -212,6 +212,28 @@ public async Task User_in_other_tenant_cannot_access_protected_endpoint_for_curr Assert.Contains(infoResponseB.StatusCode, new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } + [Fact] + public async Task RefreshToken_FromTenantA_IsRejected_ByTenantB() + { + await EnsureTenantsExistAsync(); + + // Create a user in Tenant A + var email = $"refresh-cross-{Guid.NewGuid():N}@example.com"; + var password = "CrossTenant1!"; + await CreateUserInTenantAsync(TenantA, email, password); + + // Login to Tenant A to get tokens + var clientA = Factory.CreateClientWithTenant(TenantA); + var loginResponse = await clientA.PostAsJsonAsync("/auth/token", new { Email = email, Password = password }); + await loginResponse.AssertSuccess(); + var tokens = await loginResponse.Content.ReadFromJsonAsync(); + + // Try to refresh with Tenant B header - should fail + var clientB = Factory.CreateClientWithTenant(TenantB); + var refreshResponse = await clientB.PostAsJsonAsync("/auth/refresh", new RefreshToken.RefreshTokenRequest(tokens!.RefreshToken!)); + Assert.False(refreshResponse.IsSuccessStatusCode); + } + #endregion #region Route Strategy Tests diff --git a/src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs b/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs similarity index 94% rename from src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs rename to tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs index fb8b4e7..d93115a 100644 --- a/src/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs +++ b/tests/Idmt.UnitTests/Configuration/IdmtOptionsValidatorTests.cs @@ -122,16 +122,17 @@ public void Validate_Fails_WhenClientFormModeAndClientUrlIsWhitespace() } [Fact] - public void Validate_Succeeds_WhenServerConfirmModeAndClientUrlIsNull() + public void Validate_Fails_WhenServerConfirmModeAndClientUrlIsNull() { - // ClientUrl is only required for ClientForm mode; ServerConfirm does not need it. + // ClientUrl is always required because password reset links use client form URLs. var options = ValidOptions(); options.Application.EmailConfirmationMode = EmailConfirmationMode.ServerConfirm; options.Application.ClientUrl = null; var result = Validate(options); - Assert.False(result.Failed); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(nameof(ApplicationOptions.ClientUrl))); } // --------------------------------------------------------------------------- @@ -206,9 +207,10 @@ public void Validate_Fails_WhenClientUrlSetAndBothFormPathsAreInvalid() } [Fact] - public void Validate_Succeeds_WhenClientUrlNullAndFormPathsAreNotChecked() + public void Validate_Fails_WhenClientUrlNullEvenWithServerConfirmMode() { - // When ClientUrl is null / not set, form paths are irrelevant and should not be checked. + // ClientUrl is always required. When it is null, the validation should fail + // for ClientUrl itself, but form paths are not checked since ClientUrl is not set. var options = ValidOptions(); options.Application.EmailConfirmationMode = EmailConfirmationMode.ServerConfirm; options.Application.ClientUrl = null; @@ -217,7 +219,8 @@ public void Validate_Succeeds_WhenClientUrlNullAndFormPathsAreNotChecked() var result = Validate(options); - Assert.False(result.Failed); + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains(nameof(ApplicationOptions.ClientUrl))); } // --------------------------------------------------------------------------- @@ -343,8 +346,7 @@ public void Validate_ReportsAllFailures_WhenMultipleRulesAreViolated() { ApiPrefix = null!, EmailConfirmationMode = EmailConfirmationMode.ClientForm, - ClientUrl = null // violates rule 2 - // form paths are null but not checked because ClientUrl is null + ClientUrl = null // violates rule 2 (always required) }, MultiTenant = new MultiTenantOptions { diff --git a/src/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs rename to tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/CreateTenantHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/DeleteTenantHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GetAllTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GetUserTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/GrantTenantAccessHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs similarity index 95% rename from src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs index 1f1337c..8685c7c 100644 --- a/src/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Admin/RevokeTenantAccessHandlerTests.cs @@ -13,6 +13,7 @@ namespace Idmt.UnitTests.Features.Admin; public class RevokeTenantAccessHandlerTests : IDisposable { private readonly Mock _tenantOpsMock; + private readonly Mock _tokenRevocationServiceMock; private readonly IdmtDbContext _dbContext; private readonly Mock> _tenantStoreMock; private readonly RevokeTenantAccess.RevokeTenantAccessHandler _handler; @@ -20,6 +21,7 @@ public class RevokeTenantAccessHandlerTests : IDisposable public RevokeTenantAccessHandlerTests() { _tenantOpsMock = new Mock(); + _tokenRevocationServiceMock = new Mock(); // InMemory DbContext var tenantAccessorMock = new Mock(); @@ -45,6 +47,7 @@ public RevokeTenantAccessHandlerTests() _dbContext, _tenantStoreMock.Object, _tenantOpsMock.Object, + _tokenRevocationServiceMock.Object, NullLogger.Instance); } diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ConfirmEmailHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/DiscoverTenantsHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ForgotPasswordHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/LoginHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs similarity index 77% rename from src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs index 7737dad..9669e57 100644 --- a/src/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Auth/LogoutHandlerTests.cs @@ -19,6 +19,7 @@ public class LogoutHandlerTests private readonly Mock> _signInManagerMock; private readonly Mock _currentUserServiceMock; private readonly Mock> _tenantContextAccessorMock; + private readonly Mock> _tenantStoreMock; private readonly IOptions _idmtOptions; private readonly Mock _tokenRevocationServiceMock; private readonly Logout.LogoutHandler _handler; @@ -38,6 +39,7 @@ public LogoutHandlerTests() _loggerMock = new Mock>(); _currentUserServiceMock = new Mock(); _tenantContextAccessorMock = new Mock>(); + _tenantStoreMock = new Mock>(); _tokenRevocationServiceMock = new Mock(); // Default: no tenant context resolved. Tests that need a resolved tenant override this. @@ -61,6 +63,7 @@ public LogoutHandlerTests() _signInManagerMock.Object, _currentUserServiceMock.Object, _tenantContextAccessorMock.Object, + _tenantStoreMock.Object, _idmtOptions, _tokenRevocationServiceMock.Object); } @@ -155,18 +158,52 @@ public async Task Logout_SkipsRevocation_WhenUserIdIsNull() } [Fact] - public async Task Logout_SkipsRevocationAndLogsWarning_WhenTenantContextIsNull() + public async Task Logout_RevokesViaFallback_WhenTenantContextIsNullButClaimResolvesToTenant() { - // Arrange: the multi-tenant strategy produced no context (e.g. header or route strategy - // does not fire during logout). The accessor default in the constructor returns null. - // The principal has a tenant claim that the warning should surface in its message. + // Arrange: the multi-tenant strategy produced no context, but the bearer principal + // carries a tenant claim. The fallback resolves the tenant from the store and revokes. var userId = Guid.NewGuid(); var tenantIdentifierFromClaim = "acme-corp"; + var tenantDbId = "acme-db-id"; var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierFromClaim, "Acme Corp")); + + _signInManagerMock + .Setup(s => s.SignOutAsync()) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(); + + // Assert: revocation succeeds via fallback + Assert.False(result.IsError); + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Logout_LogsWarning_WhenTenantContextIsNullAndClaimCannotBeResolved() + { + // Arrange: no tenant context and the claim identifier cannot be found in the store. + var userId = Guid.NewGuid(); + var tenantIdentifierFromClaim = "unknown-tenant"; + var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); + + _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); + _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); + + // Store returns null — tenant not found + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync((IdmtTenantInfo?)null); + _signInManagerMock .Setup(s => s.SignOutAsync()) .Returns(Task.CompletedTask); @@ -174,14 +211,11 @@ public async Task Logout_SkipsRevocationAndLogsWarning_WhenTenantContextIsNull() // Act var result = await _handler.HandleAsync(); - // Assert: sign-out still returns 204 — the user is signed out even without revocation + // Assert: sign-out succeeds but revocation skipped Assert.False(result.IsError); _tokenRevocationServiceMock.Verify( x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - - // Assert: warning is logged and identifies the tenant from bearer claims so operators - // can diagnose the misconfigured strategy VerifyLogWarningContains(tenantIdentifierFromClaim); } @@ -209,16 +243,18 @@ public async Task Logout_LogsWarning_WithNotPresentPlaceholder_WhenBothTenantCon x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - VerifyLogWarningContains(""); + VerifyLogWarningContains("no tenant context resolved"); } [Fact] - public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull() + public async Task Logout_UsesClaimFallback_WhenTenantContextExistsButTenantInfoIsNull() { // Arrange: Finbuckle returned a context object (resolution ran) but found no matching // tenant store entry — TenantInfo is null, so Id cannot be resolved. + // The fallback reads the claim and resolves via the store. var userId = Guid.NewGuid(); - var tenantIdentifierFromClaim = "unknown-tenant"; + var tenantIdentifierFromClaim = "resolved-tenant"; + var tenantDbId = "resolved-db-id"; var principal = BuildPrincipalWithTenantClaim(TenantClaimKey, tenantIdentifierFromClaim); _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); @@ -230,6 +266,10 @@ public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull( .SetupGet(a => a.MultiTenantContext) .Returns(contextWithNullTenantInfo.Object); + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierFromClaim)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierFromClaim, "Resolved Tenant")); + _signInManagerMock .Setup(s => s.SignOutAsync()) .Returns(Task.CompletedTask); @@ -237,22 +277,22 @@ public async Task Logout_LogsWarning_WhenTenantContextExistsButTenantInfoIsNull( // Act var result = await _handler.HandleAsync(); - // Assert + // Assert: revocation proceeds via fallback Assert.False(result.IsError); _tokenRevocationServiceMock.Verify( - x => x.RevokeUserTokensAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - - VerifyLogWarningContains(tenantIdentifierFromClaim); + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); } [Fact] - public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWarning() + public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForFallback() { // Arrange: a non-default claim key is configured; the handler must read the tenant - // identifier from the correct claim type when composing the warning message. + // identifier from the correct claim type for the fallback resolution. const string customClaimKey = "custom_tenant_claim"; const string tenantIdentifierValue = "my-org"; + const string tenantDbId = "my-org-db-id"; + var userId = Guid.NewGuid(); var options = Options.Create(new IdmtOptions { @@ -266,10 +306,13 @@ public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWa }); var principal = BuildPrincipalWithTenantClaim(customClaimKey, tenantIdentifierValue); - _currentUserServiceMock.SetupGet(c => c.UserId).Returns(Guid.NewGuid()); + _currentUserServiceMock.SetupGet(c => c.UserId).Returns(userId); _currentUserServiceMock.SetupGet(c => c.User).Returns(principal); - // Tenant context remains null (default) + // Tenant context remains null (default) — triggers fallback + _tenantStoreMock + .Setup(x => x.GetByIdentifierAsync(tenantIdentifierValue)) + .ReturnsAsync(new IdmtTenantInfo(tenantDbId, tenantIdentifierValue, "My Org")); _signInManagerMock .Setup(s => s.SignOutAsync()) @@ -280,14 +323,17 @@ public async Task Logout_UsesConfiguredClaimKey_WhenReadingTenantIdentifierForWa _signInManagerMock.Object, _currentUserServiceMock.Object, _tenantContextAccessorMock.Object, + _tenantStoreMock.Object, options, _tokenRevocationServiceMock.Object); // Act await handlerWithCustomOptions.HandleAsync(); - // Assert: warning contains the value from the custom claim key - VerifyLogWarningContains(tenantIdentifierValue); + // Assert: revocation called with correct tenant from custom claim key + _tokenRevocationServiceMock.Verify( + x => x.RevokeUserTokensAsync(userId, tenantDbId, It.IsAny()), + Times.Once); } #region Helpers diff --git a/src/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/RefreshTokenHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ResendConfirmationEmailHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/ResetPasswordHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs b/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Auth/TokenLoginHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs b/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs rename to tests/Idmt.UnitTests/Features/Health/BasicHealthCheckTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/GetUserInfoHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/RegisterHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UnregisterHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UpdateUserHandlerTests.cs diff --git a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs similarity index 97% rename from src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs rename to tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs index 7cbce7d..060d2ee 100644 --- a/src/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs +++ b/tests/Idmt.UnitTests/Features/Manage/UpdateUserInfoHandlerTests.cs @@ -18,6 +18,8 @@ public class UpdateUserInfoHandlerTests : IDisposable private readonly Mock> _userManagerMock; private readonly Mock _linkGeneratorMock; private readonly Mock> _emailSenderMock; + private readonly Mock _handlerCurrentUserServiceMock; + private readonly Mock _tokenRevocationServiceMock; private readonly IdmtDbContext _dbContext; private readonly UpdateUserInfo.UpdateUserInfoHandler _handler; @@ -29,6 +31,8 @@ public UpdateUserInfoHandlerTests() _linkGeneratorMock = new Mock(); _emailSenderMock = new Mock>(); + _handlerCurrentUserServiceMock = new Mock(); + _tokenRevocationServiceMock = new Mock(); var tenantAccessorMock = new Mock(); var dummyTenant = new IdmtTenantInfo("system-test-tenant", "system-test", "System Test Tenant"); @@ -54,6 +58,8 @@ public UpdateUserInfoHandlerTests() _dbContext, _linkGeneratorMock.Object, _emailSenderMock.Object, + _handlerCurrentUserServiceMock.Object, + _tokenRevocationServiceMock.Object, NullLogger.Instance); } diff --git a/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj b/tests/Idmt.UnitTests/Idmt.UnitTests.csproj similarity index 100% rename from src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj rename to tests/Idmt.UnitTests/Idmt.UnitTests.csproj diff --git a/src/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs b/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs rename to tests/Idmt.UnitTests/Middleware/CurrentUserMiddlewareTests.cs diff --git a/src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs b/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs rename to tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs diff --git a/src/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs b/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs rename to tests/Idmt.UnitTests/Models/IdmtTenantInfoTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs b/tests/Idmt.UnitTests/Services/CoreServicesTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs rename to tests/Idmt.UnitTests/Services/CoreServicesTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs b/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs rename to tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs b/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs rename to tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs rename to tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs b/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs rename to tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs b/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs rename to tests/Idmt.UnitTests/Services/TokenRevocationCleanupServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs b/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs rename to tests/Idmt.UnitTests/Services/TokenRevocationServiceTests.cs diff --git a/src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs b/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs rename to tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs diff --git a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs b/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs similarity index 100% rename from src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs rename to tests/Idmt.UnitTests/Validation/ValidatorsTests.cs From cc4ab618aa8f847da7231b6ff6ee68ad28db1676 Mon Sep 17 00:00:00 2001 From: idotta Date: Sun, 15 Mar 2026 01:25:06 -0300 Subject: [PATCH 2/2] Update rate limiting options to be disabled by default and adjust documentation accordingly --- Idmt.Plugin/Configuration/IdmtOptions.cs | 5 +++-- README.md | 10 +++++----- tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs | 1 - .../Configuration/RateLimitingOptionsTests.cs | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Idmt.Plugin/Configuration/IdmtOptions.cs b/Idmt.Plugin/Configuration/IdmtOptions.cs index 58771c6..49e4878 100644 --- a/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -304,9 +304,10 @@ public class DatabaseOptions public class RateLimitingOptions { /// - /// Enable built-in rate limiting for auth endpoints. Default: true. + /// Enable built-in rate limiting for auth endpoints. Default: false. + /// Opt-in for production deployments to protect against brute-force and email-flooding attacks. /// - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = false; /// /// Maximum number of requests allowed per window for auth endpoints. Default: 10. diff --git a/README.md b/README.md index 02f71de..e250cb4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ An opinionated .NET 10 library for self-hosted identity management and multi-ten - 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 +- Optional per-IP fixed-window rate limiting on all auth endpoints (opt-in) - Token revocation on logout with background cleanup - Account lockout (5 failed attempts / 5-minute window) - PII masking in all structured log output @@ -97,7 +97,7 @@ app.Run(); "DatabaseInitialization": "Migrate" }, "RateLimiting": { - "Enabled": true, + "Enabled": false, "PermitLimit": 10, "WindowInSeconds": 60 } @@ -111,7 +111,7 @@ app.Run(); - `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. +- `RateLimiting` — per-IP fixed-window limiter applied to all `/auth` endpoints. Disabled by default; set `Enabled: true` in production to protect against brute-force and email-flooding attacks. --- @@ -121,7 +121,7 @@ All endpoints are mounted under `ApiPrefix` (default `/api/v1`). ### Authentication — `/auth` -Rate-limited. All endpoints are public except `/auth/logout`. +Rate-limited when enabled. All endpoints are public except `/auth/logout`. | Method | Path | Auth Required | Description | |--------|------|:---:|-------------| @@ -212,7 +212,7 @@ When using bearer tokens, a middleware (`ValidateBearerTokenTenantMiddleware`) v ## Security -- Per-IP fixed-window rate limiting on all `/auth` endpoints (configurable via `RateLimiting`) +- Optional per-IP fixed-window rate limiting on all `/auth` endpoints (disabled by default; enable via `RateLimiting.Enabled`) - `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 diff --git a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs index 4c60066..eb1740c 100644 --- a/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs +++ b/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs @@ -50,7 +50,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // migrations, and the test factory's SeedAsync initialises the schema directly // via EnsureCreatedAsync before the seeding scope runs. { "Idmt:Database:DatabaseInitialization", "EnsureCreated" }, - { "Idmt:RateLimiting:Enabled", "false" }, }; // Add strategies as indexed array for proper deserialization for (int i = 0; i < _strategies.Length; i++) diff --git a/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs index 72fc0b8..5e92cf6 100644 --- a/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs +++ b/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs @@ -10,10 +10,10 @@ public class RateLimitingOptionsTests // ------------------------------------------------------------------ [Fact] - public void Enabled_DefaultsToTrue() + public void Enabled_DefaultsToFalse() { var options = new RateLimitingOptions(); - Assert.True(options.Enabled); + Assert.False(options.Enabled); } [Fact] @@ -40,18 +40,18 @@ public void IdmtOptions_ExposesRateLimitingProperty_WithDefaults() var idmtOptions = new IdmtOptions(); Assert.NotNull(idmtOptions.RateLimiting); - Assert.True(idmtOptions.RateLimiting.Enabled); + Assert.False(idmtOptions.RateLimiting.Enabled); Assert.Equal(10, idmtOptions.RateLimiting.PermitLimit); Assert.Equal(60, idmtOptions.RateLimiting.WindowInSeconds); } [Fact] - public void IdmtOptions_Default_HasRateLimitingEnabled() + public void IdmtOptions_Default_HasRateLimitingDisabled() { var defaults = IdmtOptions.Default; Assert.NotNull(defaults.RateLimiting); - Assert.True(defaults.RateLimiting.Enabled); + Assert.False(defaults.RateLimiting.Enabled); } // ------------------------------------------------------------------