Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Original file line number Diff line number Diff line change
Expand Up @@ -304,17 +304,18 @@ public class DatabaseOptions
public class RateLimitingOptions
{
/// <summary>
/// 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.
/// </summary>
public bool Enabled { get; set; } = true;
public bool Enabled { get; set; } = false;

/// <summary>
/// Maximum number of requests allowed per window for auth endpoints. Default: 10.
/// </summary>
public int PermitLimit { get; set; } = 10;

/// <summary>
/// Duration of the sliding window in seconds. Default: 60.
/// Duration of the fixed window in seconds. Default: 60.
/// </summary>
public int WindowInSeconds { get; set; } = 60;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,15 @@ private static async Task SeedDefaultDataAsync(IServiceProvider services)
{
var options = services.GetRequiredService<IOptions<IdmtOptions>>();
var createTenantHandler = services.GetRequiredService<CreateTenant.ICreateTenantHandler>();
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IOptions<IdmtOptions>>().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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ public async Task<ErrorOr<Success>> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ internal sealed class RevokeTenantAccessHandler(
IdmtDbContext dbContext,
IMultiTenantStore<IdmtTenantInfo> tenantStore,
ITenantOperationService tenantOps,
ITokenRevocationService tokenRevocationService,
ILogger<RevokeTenantAccessHandler> logger) : IRevokeTenantAccessHandler
{
public async Task<ErrorOr<Success>> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -61,6 +62,9 @@ public async Task<ErrorOr<Success>> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,6 +28,7 @@ internal sealed class LogoutHandler(
SignInManager<IdmtUser> signInManager,
ICurrentUserService currentUserService,
IMultiTenantContextAccessor<IdmtTenantInfo> tenantContextAccessor,
IMultiTenantStore<IdmtTenantInfo> tenantStore,
IOptions<IdmtOptions> idmtOptions,
ITokenRevocationService tokenRevocationService)
: ILogoutHandler
Expand All @@ -48,21 +50,33 @@ public async Task<ErrorOr<Success>> 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 ?? "<not present in claims>");
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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ internal sealed class UpdateUserInfoHandler(
IdmtDbContext dbContext,
IIdmtLinkGenerator linkGenerator,
IEmailSender<IdmtUser> emailSender,
ICurrentUserService currentUserService,
ITokenRevocationService tokenRevocationService,
ILogger<UpdateUserInfoHandler> logger) : IUpdateUserInfoHandler
{
public async Task<ErrorOr<Success>> HandleAsync(
Expand Down Expand Up @@ -101,6 +103,12 @@ public async Task<ErrorOr<Success>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</PropertyGroup>

<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>

<ItemGroup>
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
35 changes: 19 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,7 +97,7 @@ app.Run();
"DatabaseInitialization": "Migrate"
},
"RateLimiting": {
"Enabled": true,
"Enabled": false,
"PermitLimit": 10,
"WindowInSeconds": 60
}
Expand All @@ -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.

---

Expand All @@ -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 |
|--------|------|:---:|-------------|
Expand All @@ -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`.

Expand All @@ -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`

Expand All @@ -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`

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -233,11 +236,11 @@ builder.Services.AddIdmt<MyDbContext>(
{
options.Application.ApiPrefix = "/api/v2";
},
customizeAuth: auth =>
customizeAuthentication: auth =>
{
// Add additional authentication schemes
},
customizeAuthz: authz =>
customizeAuthorization: authz =>
{
// Add additional authorization policies
}
Expand Down
Loading
Loading