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 97%
rename from src/Idmt.Plugin/Configuration/IdmtOptions.cs
rename to Idmt.Plugin/Configuration/IdmtOptions.cs
index d50b85e..49e4878 100644
--- a/src/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.
@@ -314,7 +315,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..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 |
|--------|------|:---:|-------------|
@@ -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.
@@ -209,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
@@ -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 99%
rename from src/tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs
rename to tests/Idmt.BasicSample.Tests/IdmtApiFactory.cs
index 4c60066..eb1740c 100644
--- a/src/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/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 91%
rename from src/tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs
rename to tests/Idmt.UnitTests/Configuration/RateLimitingOptionsTests.cs
index 72fc0b8..5e92cf6 100644
--- a/src/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);
}
// ------------------------------------------------------------------
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