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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ jobs:
dotnet-version: 9.0.x

- name: Restore dependencies
run: dotnet restore src/Idmt.sln
run: dotnet restore src/Idmt.slnx

- name: Format check
run: dotnet format src/Idmt.sln --verify-no-changes --verbosity diagnostic
run: dotnet format src/Idmt.slnx --verify-no-changes --verbosity diagnostic

- name: Build
run: dotnet build src/Idmt.sln --no-restore --configuration Release /p:TreatWarningsAsErrors=true
run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:TreatWarningsAsErrors=true

- name: Run analyzers
run: dotnet build src/Idmt.sln --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true
run: dotnet build src/Idmt.slnx --no-restore --configuration Release /p:RunAnalyzers=true /p:EnforceCodeStyleInBuild=true

- name: Test
run: dotnet test src/Idmt.sln --no-build --configuration Release --verbosity normal
run: dotnet test src/Idmt.slnx --no-build --configuration Release --verbosity normal

- name: Pack
run: dotnet pack src/Idmt.Plugin/Idmt.Plugin.csproj --no-build --configuration Release --output .
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ jobs:
dotnet-version: 9.0.x

- name: Restore dependencies
run: dotnet restore src/Idmt.sln
run: dotnet restore src/Idmt.slnx

- name: Build
run: dotnet build src/Idmt.sln --no-restore --configuration Release
run: dotnet build src/Idmt.slnx --no-restore --configuration Release

- name: Test
run: dotnet test src/Idmt.sln --no-build --configuration Release
run: dotnet test src/Idmt.slnx --no-build --configuration Release

- name: Set Version
id: get_version
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ nunit-*.xml
*.db
*.db-shm
*.db-wal

.claude/settings.local.json
8 changes: 8 additions & 0 deletions src/Idmt.Plugin/Configuration/IdmtEndpointNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Idmt.Plugin.Configuration;

internal static class IdmtEndpointNames
{
public const string ConfirmEmail = "ConfirmEmail";
public const string ConfirmEmailDirect = "ConfirmEmail-direct";
public const string PasswordReset = "ResetPassword";
}
133 changes: 116 additions & 17 deletions src/Idmt.Plugin/Configuration/IdmtOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class IdmtOptions
/// <summary>
/// Identity configuration options
/// </summary>
public AuthOptions Identity { get; set; } = new();
public IdmtAuthOptions Identity { get; set; } = new();

/// <summary>
/// Multi-tenant configuration options
Expand All @@ -35,15 +35,45 @@ public class IdmtOptions
/// Database configuration options
/// </summary>
public DatabaseOptions Database { get; set; } = new();

/// <summary>
/// Rate limiting configuration options for auth endpoints
/// </summary>
public RateLimitingOptions RateLimiting { get; set; } = new();

}

/// <summary>
/// Controls how email confirmation links behave.
/// </summary>
public enum EmailConfirmationMode
{
/// <summary>
/// Email link points to GET /auth/confirm-email on the server, which confirms
/// the email directly (like Microsoft's reference implementation).
/// No client-side form needed for email confirmation.
/// </summary>
ServerConfirm,

/// <summary>
/// Email link points to ClientUrl/ConfirmEmailFormPath on the client app.
/// The client reads the token from the URL and calls POST /auth/confirm-email.
/// Default for SPA/mobile apps.
/// </summary>
ClientForm
}

/// <summary>
/// Application configuration options
/// </summary>
public class ApplicationOptions
{
public const string PasswordResetEndpointName = "ResetPassword";
public const string ConfirmEmailEndpointName = "ConfirmEmail";
/// <summary>
/// URI prefix applied to all IDMT endpoint groups (/auth, /manage, /admin, /health).
/// Defaults to "/api/v1". Set to "" to restore the legacy unprefixed behavior.
/// Examples: "/api/v1", "/v2", "/api/v2", ""
/// </summary>
public string ApiPrefix { get; set; } = "/api/v1";

/// <summary>
/// Base URL of the client application, if any (e.g. "https://myapp.com")
Expand All @@ -57,12 +87,19 @@ public class ApplicationOptions

public string ResetPasswordFormPath { get; set; } = "/reset-password";
public string ConfirmEmailFormPath { get; set; } = "/confirm-email";

/// <summary>
/// Controls how email confirmation links are generated.
/// ServerConfirm: link hits GET /auth/confirm-email which confirms directly.
/// ClientForm: link points to ClientUrl/ConfirmEmailFormPath for SPA handling.
/// </summary>
public EmailConfirmationMode EmailConfirmationMode { get; set; } = EmailConfirmationMode.ClientForm;
}

/// <summary>
/// ASP.NET Core Identity configuration
/// </summary>
public class AuthOptions
public class IdmtAuthOptions
{
public const string CookieOrBearerScheme = "CookieOrBearer";

Expand All @@ -75,7 +112,7 @@ public class AuthOptions
/// <summary>
/// Password requirements
/// </summary>
public PasswordOptions Password { get; set; } = new();
public IdmtPasswordOptions Password { get; set; } = new();

/// <summary>
/// User requirements
Expand All @@ -90,7 +127,7 @@ public class AuthOptions
/// <summary>
/// Cookie configuration options
/// </summary>
public CookieOptions Cookie { get; set; } = new();
public IdmtCookieOptions Cookie { get; set; } = new();

/// <summary>
/// Bearer token configuration options
Expand All @@ -106,13 +143,13 @@ public class AuthOptions
/// <summary>
/// Password configuration options
/// </summary>
public class PasswordOptions
public class IdmtPasswordOptions
{
public bool RequireDigit { get; set; } = true;
public bool RequireLowercase { get; set; } = true;
public bool RequireUppercase { get; set; } = true;
public bool RequireNonAlphanumeric { get; set; } = false;
public int RequiredLength { get; set; } = 6;
public int RequiredLength { get; set; } = 8;
public int RequiredUniqueChars { get; set; } = 1;
}

Expand All @@ -130,19 +167,34 @@ public class UserOptions
/// </summary>
public class SignInOptions
{
public bool RequireConfirmedEmail { get; set; } = false;
public bool RequireConfirmedEmail { get; set; } = true;
public bool RequireConfirmedPhoneNumber { get; set; } = false;
}

/// <summary>
/// Cookie configuration options
/// </summary>
public class CookieOptions
public class IdmtCookieOptions
{
public string Name { get; set; } = ".Idmt.Application";
public bool HttpOnly { get; set; } = true;
public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
public Microsoft.AspNetCore.Http.SameSiteMode SameSite { get; set; } = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;

/// <summary>
/// Controls the SameSite attribute of the authentication cookie.
/// Defaults to <see cref="Microsoft.AspNetCore.Http.SameSiteMode.Strict"/>, which means the
/// browser will never send the cookie on cross-site requests (neither top-level navigations
/// nor sub-resource loads). This is the strongest available CSRF protection at the cookie
/// layer and removes the need for anti-forgery tokens on state-mutating endpoints that rely
/// solely on cookie authentication.
///
/// Change to <see cref="Microsoft.AspNetCore.Http.SameSiteMode.Lax"/> only if your
/// application requires cookie preservation on top-level cross-site GET navigations (e.g.
/// OAuth / OIDC redirect flows), and compensate with explicit anti-forgery validation on
/// every state-mutating endpoint.
/// </summary>
public Microsoft.AspNetCore.Http.SameSiteMode SameSite { get; set; } = Microsoft.AspNetCore.Http.SameSiteMode.Strict;

public TimeSpan ExpireTimeSpan { get; set; } = TimeSpan.FromDays(14);
public bool SlidingExpiration { get; set; } = true;
public bool IsRedirectEnabled { get; set; } = false;
Expand Down Expand Up @@ -191,7 +243,7 @@ public class MultiTenantOptions
/// </summary>
public const string DefaultTenantIdentifier = "system-tenant";

public string DefaultTenantDisplayName { get; set; } = "System Tenant";
public string DefaultTenantName { get; set; } = "System Tenant";

/// <summary>
/// Tenant resolution strategy (header, subdomain, etc.)
Expand All @@ -204,18 +256,65 @@ public class MultiTenantOptions
public Dictionary<string, string> StrategyOptions { get; set; } = [];
}

/// <summary>
/// Controls how the IDMT database schema is initialized on startup.
/// </summary>
public enum DatabaseInitializationMode
{
/// <summary>
/// Use EF Core Migrations. Consumers must create and apply migrations themselves.
/// Recommended for production. Default.
/// </summary>
Migrate,

/// <summary>
/// Use EnsureCreated for quick setup. Not compatible with migrations.
/// Suitable for development, testing, and prototyping only.
/// </summary>
EnsureCreated,

/// <summary>
/// Skip automatic database initialization. Consumer manages the database schema externally.
/// </summary>
None
}

/// <summary>
/// Database configuration options
/// </summary>
public class DatabaseOptions
{
/// <summary>
/// Connection string template with placeholder for tenant's properties
/// Controls how the database schema is initialized on startup.
/// <see cref="DatabaseInitializationMode.Migrate"/> runs EF Core migrations and is the
/// default for production use. <see cref="DatabaseInitializationMode.EnsureCreated"/> is
/// suitable for development, testing, and prototyping where migrations are not used.
/// <see cref="DatabaseInitializationMode.None"/> skips initialization entirely and leaves
/// schema management to the consumer.
/// </summary>
public DatabaseInitializationMode DatabaseInitialization { get; set; } = DatabaseInitializationMode.Migrate;
}

/// <summary>
/// Rate limiting configuration for IDMT auth endpoints.
/// When enabled, a fixed-window limiter named "idmt-auth" is registered and applied
/// to all authentication endpoints (login, token, forgot-password, etc.) to protect
/// against brute-force and email-flooding attacks.
/// </summary>
public class RateLimitingOptions
{
/// <summary>
/// Enable built-in rate limiting for auth endpoints. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;

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

/// <summary>
/// Auto-migrate database on startup
/// Duration of the sliding window in seconds. Default: 60.
/// </summary>
public bool AutoMigrate { get; set; } = false;
public int WindowInSeconds { get; set; } = 60;
}
95 changes: 95 additions & 0 deletions src/Idmt.Plugin/Configuration/IdmtOptionsValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Microsoft.Extensions.Options;

namespace Idmt.Plugin.Configuration;

/// <summary>
/// Validates <see cref="IdmtOptions"/> at application startup so that
/// misconfigured options are reported immediately rather than causing
/// unexpected failures at runtime.
/// </summary>
public sealed class IdmtOptionsValidator : IValidateOptions<IdmtOptions>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, IdmtOptions options)
{
var failures = new List<string>();

ValidateApplicationOptions(options.Application, failures);
ValidateMultiTenantOptions(options.MultiTenant, failures);

return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}

private static void ValidateApplicationOptions(ApplicationOptions application, List<string> failures)
{
// Rule 1: ApiPrefix must not be null or empty.
// An empty string is the documented opt-out for legacy unprefixed behavior,
// but null indicates the property was explicitly cleared and is misconfigured.
if (application.ApiPrefix is null)
{
failures.Add(
$"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ApiPrefix)} must not be null. " +
"Use an empty string \"\" to disable the prefix or provide a value such as \"/api/v1\".");
}

// Rule 2: When EmailConfirmationMode is ClientForm, ClientUrl is required.
if (application.EmailConfirmationMode == EmailConfirmationMode.ClientForm &&
string.IsNullOrWhiteSpace(application.ClientUrl))
{
failures.Add(
$"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ClientUrl)} must not be null or empty " +
$"when {nameof(ApplicationOptions.EmailConfirmationMode)} is {nameof(EmailConfirmationMode.ClientForm)}.");
}

// Rule 3: When ClientUrl is set, the client-side form paths must also be configured.
if (!string.IsNullOrWhiteSpace(application.ClientUrl))
{
if (string.IsNullOrWhiteSpace(application.ConfirmEmailFormPath))
{
failures.Add(
$"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ConfirmEmailFormPath)} must not be null or empty " +
$"when {nameof(ApplicationOptions.ClientUrl)} is set.");
}

if (string.IsNullOrWhiteSpace(application.ResetPasswordFormPath))
{
failures.Add(
$"{nameof(IdmtOptions.Application)}.{nameof(ApplicationOptions.ResetPasswordFormPath)} must not be null or empty " +
$"when {nameof(ApplicationOptions.ClientUrl)} is set.");
}
}
}

private static void ValidateMultiTenantOptions(MultiTenantOptions multiTenant, List<string> failures)
{
// Rule 4: The constant DefaultTenantIdentifier is a compile-time value and cannot be
// null or empty. Validate it defensively so that any future refactor to an instance
// property is caught immediately.
if (string.IsNullOrWhiteSpace(MultiTenantOptions.DefaultTenantIdentifier))
{
failures.Add(
$"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.DefaultTenantIdentifier)} must not be null or empty.");
}

// Rule 5: DefaultTenantName is configurable and must not be null or empty.
if (string.IsNullOrWhiteSpace(multiTenant.DefaultTenantName))
{
failures.Add(
$"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.DefaultTenantName)} must not be null or empty.");
}

// Rule 6: At least one tenant resolution strategy must be configured.
// The Strategies array controls which strategies (header, claim, route, basepath) are
// active. An empty array means no tenant can ever be resolved, which is always a
// misconfiguration. StrategyOptions holds per-strategy overrides and may be empty.
if (multiTenant.Strategies.Length == 0)
{
failures.Add(
$"{nameof(IdmtOptions.MultiTenant)}.{nameof(MultiTenantOptions.Strategies)} must contain at least one entry. " +
$"Supported values: {IdmtMultiTenantStrategy.Header}, {IdmtMultiTenantStrategy.Claim}, " +
$"{IdmtMultiTenantStrategy.Route}, {IdmtMultiTenantStrategy.BasePath}.");
}
}
}
8 changes: 8 additions & 0 deletions src/Idmt.Plugin/Constants/AuditAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Idmt.Plugin.Constants;

public enum AuditAction
{
Created,
Modified,
Deleted
}
6 changes: 6 additions & 0 deletions src/Idmt.Plugin/Constants/IdmtClaimTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Idmt.Plugin.Constants;

public static class IdmtClaimTypes
{
public const string IsActive = "is_active";
}
Loading