diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 25c396c5..39c4c0a7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,15 @@ # 🚀 Pull Request -Thank you for contributing to **UltimateAuth**! -Please complete the following checklist to help us review your PR effectively. + + ## 📘 Summary -Describe what this PR does and why it’s needed. + ## 🔍 Details -Explain any important implementation details, design decisions, or considerations. + ## 🧩 Related Issues diff --git a/README.md b/README.md index af08c023..9e7a8215 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | | First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | 07.04.2026 | -| First Release* | 0.1.0 | Fully Documented & Quality Tested | ✅ Completed | 04.10.2026 | +| First Release* | 0.1.0 | Fully Documented & Quality Tested | ✅ Completed | 08.10.2026 | | Product Expansion | 0.2.0 | Full Auth Modes | 🟡 In Progress | Q4 2026 | | Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟡 In Progress | Q4 2026 | | Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | 🔜 Planned | Q1 2027 | diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs similarity index 98% rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.Designer.cs rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs index 96400072..3f1152e9 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.Designer.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs @@ -11,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations { [DbContext(typeof(UAuthDbContext))] - [Migration("20260412205559_InitUltimateAuth")] - partial class InitUltimateAuth + [Migration("20261004164613_001_Initial")] + partial class _001_Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -434,8 +434,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "RootId") .IsUnique(); - b.HasIndex("Tenant", "UserKey") - .IsUnique(); + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); b.ToTable("UAuth_SessionRoots", (string)null); }); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs similarity index 98% rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.cs rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs index 35dc371f..77f4d9bf 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20260412205559_InitUltimateAuth.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations { /// - public partial class InitUltimateAuth : Migration + public partial class _001_Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -432,8 +432,12 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.CreateIndex( name: "IX_UAuth_SessionRoots_Tenant_UserKey", table: "UAuth_SessionRoots", - columns: new[] { "Tenant", "UserKey" }, - unique: true); + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_UserKey_RevokedAt", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "UserKey", "RevokedAt" }); migrationBuilder.CreateIndex( name: "IX_UAuth_Sessions_Tenant_ChainId", diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index 4000ca4d..bef97e5f 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class UAuthDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -431,8 +431,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "RootId") .IsUnique(); - b.HasIndex("Tenant", "UserKey") - .IsUnique(); + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); b.ToTable("UAuth_SessionRoots", (string)null); }); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Program.cs index 0de4d2c1..fe875991 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Program.cs @@ -39,7 +39,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Behavior.AllowMultipleUsernames = true; o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthEntityFrameworkCore(db => diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db index 4e86411b..c9cf4b4e 100644 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm index 274b15c6..86e2b19d 100644 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-shm differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal index 4eab4800..932093f3 100644 Binary files a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db-wal differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 1182dcf7..54e3346e 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -37,7 +37,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Behavior.AllowMultipleUsernames = true; o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthInMemory() diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs similarity index 98% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs index 1302213b..7df8d5ae 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.Designer.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs @@ -11,14 +11,14 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations { [DbContext(typeof(UAuthDbContext))] - [Migration("20260406192328_InitUltimateAuth")] - partial class InitUltimateAuth + [Migration("20261004164018_001_Initial")] + partial class _001_Initial { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -434,8 +434,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "RootId") .IsUnique(); - b.HasIndex("Tenant", "UserKey") - .IsUnique(); + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); b.ToTable("UAuth_SessionRoots", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs similarity index 98% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs index bd4101fd..0d43c8e2 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260406192328_InitUltimateAuth.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations { /// - public partial class InitUltimateAuth : Migration + public partial class _001_Initial : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -432,8 +432,12 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.CreateIndex( name: "IX_UAuth_SessionRoots_Tenant_UserKey", table: "UAuth_SessionRoots", - columns: new[] { "Tenant", "UserKey" }, - unique: true); + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_UserKey_RevokedAt", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "UserKey", "RevokedAt" }); migrationBuilder.CreateIndex( name: "IX_UAuth_Sessions_Tenant_ChainId", diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs index af13077f..65798d28 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class UAuthDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => { @@ -431,8 +431,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "RootId") .IsUnique(); - b.HasIndex("Tenant", "UserKey") - .IsUnique(); + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); b.ToTable("UAuth_SessionRoots", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs index 99fe67c7..33b5d2af 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs @@ -51,7 +51,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Behavior.AllowMultipleUsernames = true; }) .AddUltimateAuthEntityFrameworkCore(db => { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db index 4e86411b..c9cf4b4e 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm index 1c694283..9f70b715 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal index b5380fc8..9f632717 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 074e07a3..b939d487 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -50,7 +50,7 @@ //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); o.Login.MaxFailedAttempts = 2; o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Behavior.AllowMultipleUsernames = true; }) .AddUltimateAuthInMemory(); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthAtomicExecutor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthAtomicExecutor.cs new file mode 100644 index 00000000..1a8f9cec --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthAtomicExecutor.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IUAuthAtomicExecutor +{ + Task ExecuteAsync(Func operation, CancellationToken ct = default); + + Task ExecuteAsync(Func> operation, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs index a7a9e51d..b346c2c9 100644 --- a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -8,3 +8,4 @@ [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Integration")] diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs new file mode 100644 index 00000000..0ab50a03 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +// TODO: Add global scope +/// +/// Defines the scope of uniqueness for an identifier. +/// +public enum UniquenessScope +{ + /// + /// No uniqueness is enforced. + /// Note that users still can't create duplicate identifiers. + /// + None = 0, + + /// + /// Uniqueness is enforced within a single user. + /// + WithinUser = 10, + + /// + /// Uniqueness is enforced within a tenant. + /// + Tenant = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs index 337cc47c..4fc140d6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -94,6 +94,7 @@ public static class UserIdentifiers public const string VerifyAdmin = "users.identifiers.verify.admin"; public const string DeleteSelf = "users.identifiers.delete.self"; public const string DeleteAdmin = "users.identifiers.delete.admin"; + public const string CheckAvailability = "users.identifiers.check-availability.anonymous"; } public static class Credentials diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs index fa21263e..f5638b2e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -42,4 +42,5 @@ public interface IUserEndpointHandler Task UnsetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task VerifyUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task CheckIdentifierAvailabilityAsync(HttpContext ctx); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 4ba6e369..686deff8 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -311,6 +311,10 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.UserIdentifiers.DeleteAdmin)) adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.CheckAvailability)) + self.MapPost("/identifiers/check-availability", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.CheckIdentifierAvailabilityAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); } if (options.Endpoints.Credentials != false) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 20cf8a70..95b36ba5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -236,7 +236,8 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs index 0fd00be7..de6bfb2b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs @@ -13,7 +13,7 @@ public sealed class IdentifierNormalizer : IIdentifierNormalizer public IdentifierNormalizer(IOptions options) { - _options = options.Value.LoginIdentifiers.Normalization; + _options = options.Value.Identifiers.Normalization; } public NormalizedIdentifier Normalize(UserIdentifierType type, string value) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs deleted file mode 100644 index 8054fa86..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; - -namespace CodeBeam.UltimateAuth.Server.Infrastructure; - -public interface IIdentifierValidator -{ - Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default); -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserIdentifierValidator.cs new file mode 100644 index 00000000..f045ef9b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserIdentifierValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserIdentifierValidator +{ + Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserProfileValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserProfileValidator.cs new file mode 100644 index 00000000..5d32d436 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserProfileValidator.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserProfileValidator +{ + Task ValidateAsync(AccessContext context, UserProfileInfo profile, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs index b3441fbe..3d16c55a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -6,11 +6,13 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; public sealed class UserCreateValidator : IUserCreateValidator { - private readonly IIdentifierValidator _identifierValidator; + private readonly IUserIdentifierValidator _identifierValidator; + private readonly IUserProfileValidator _profileValidator; - public UserCreateValidator(IIdentifierValidator identifierValidator) + public UserCreateValidator(IUserIdentifierValidator identifierValidator, IUserProfileValidator profileValidator) { _identifierValidator = identifierValidator; + _profileValidator = profileValidator; } public async Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) @@ -57,6 +59,30 @@ public async Task ValidateAsync(AccessContext context errors.AddRange(r.Errors); } + var effectiveDisplayName = + request.DisplayName + ?? request.UserName + ?? request.Email + ?? request.Phone; + + var profileValidation = await _profileValidator.ValidateAsync(context, + new UserProfileInfo + { + ProfileKey = ProfileKey.Default, + FirstName = request.FirstName, + LastName = request.LastName, + DisplayName = effectiveDisplayName, + BirthDate = request.BirthDate, + Gender = request.Gender, + Bio = request.Bio, + Language = request.Language, + TimeZone = request.TimeZone, + Culture = request.Culture + }, + ct); + + errors.AddRange(profileValidation.Errors); + if (errors.Count == 0) return UserCreateValidatorResult.Success(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserIdentifierValidator.cs similarity index 83% rename from src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs rename to src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserIdentifierValidator.cs index 627c73e1..78373a9a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserIdentifierValidator.cs @@ -6,16 +6,16 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -public sealed class IdentifierValidator : IIdentifierValidator +public sealed class UserIdentifierValidator : IUserIdentifierValidator { private readonly UAuthIdentifierValidationOptions _options; - public IdentifierValidator(IOptions options) + public UserIdentifierValidator(IOptions options) { _options = options.Value.IdentifierValidation; } - public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) + public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -24,7 +24,7 @@ public Task ValidateAsync(AccessContext context, Use if (string.IsNullOrWhiteSpace(identifier.Value)) { errors.Add(new("identifier_empty")); - return Task.FromResult(IdentifierValidationResult.Failed(errors)); + return Task.FromResult(UserIdentifierValidationResult.Failed(errors)); } identifier.Value = identifier.Value.Trim(); @@ -45,9 +45,9 @@ public Task ValidateAsync(AccessContext context, Use } if (errors.Count == 0) - return Task.FromResult(IdentifierValidationResult.Success()); + return Task.FromResult(UserIdentifierValidationResult.Success()); - return Task.FromResult(IdentifierValidationResult.Failed(errors)); + return Task.FromResult(UserIdentifierValidationResult.Failed(errors)); } private void ValidateUsername(string username, List errors) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs new file mode 100644 index 00000000..7d9ac809 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UserProfileValidator : IUserProfileValidator +{ + public Task ValidateAsync(AccessContext context, UserProfileInfo profile, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(UserProfileValidationResult.Success()); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs new file mode 100644 index 00000000..cbe7e9ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs @@ -0,0 +1,31 @@ +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierBehaviorOptions +{ + public bool AllowUsernameChange { get; set; } = true; + + public bool AllowMultipleUsernames { get; set; } = false; + public bool AllowMultipleEmail { get; set; } = true; + public bool AllowMultiplePhone { get; set; } = true; + + public bool RequireUsernameIdentifier { get; set; } = false; + + public bool RequireEmailVerification { get; set; } = false; + public bool RequirePhoneVerification { get; set; } = false; + + public bool AllowAdminOverride { get; set; } = true; + public bool AllowUserOverride { get; set; } = true; + + internal UAuthIdentifierBehaviorOptions Clone() => new() + { + AllowUsernameChange = AllowUsernameChange, + AllowMultipleUsernames = AllowMultipleUsernames, + AllowMultipleEmail = AllowMultipleEmail, + AllowMultiplePhone = AllowMultiplePhone, + RequireUsernameIdentifier = RequireUsernameIdentifier, + RequireEmailVerification = RequireEmailVerification, + RequirePhoneVerification = RequirePhoneVerification, + AllowAdminOverride = AllowAdminOverride, + AllowUserOverride = AllowUserOverride + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierNormalizationOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierNormalizationOptions.cs new file mode 100644 index 00000000..0b8d5f95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierNormalizationOptions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierNormalizationOptions +{ + public CaseHandling UsernameCase { get; set; } = CaseHandling.ToLower; + public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower; + public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve; + + internal UAuthIdentifierNormalizationOptions Clone() => new() + { + UsernameCase = UsernameCase, + EmailCase = EmailCase, + CustomCase = CustomCase + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs index 1d7c9c6a..cde7676b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs @@ -2,28 +2,19 @@ public sealed class UAuthIdentifierOptions { - public bool AllowUsernameChange { get; set; } = true; - public bool AllowMultipleUsernames { get; set; } = false; - public bool AllowMultipleEmail { get; set; } = true; - public bool AllowMultiplePhone { get; set; } = true; + public UAuthIdentifierBehaviorOptions Behavior { get; set; } = new(); - public bool RequireUsernameIdentifier { get; set; } = false; - public bool RequireEmailVerification { get; set; } = false; - public bool RequirePhoneVerification { get; set; } = false; + public UAuthIdentifierValidationOptions Validation { get; set; } = new(); - public bool AllowAdminOverride { get; set; } = true; - public bool AllowUserOverride { get; set; } = true; + public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new(); + + public UAuthIdentifierUniquenessOptions Uniqueness { get; set; } = new(); internal UAuthIdentifierOptions Clone() => new() { - AllowUsernameChange = AllowUsernameChange, - AllowMultipleUsernames = AllowMultipleUsernames, - AllowMultipleEmail = AllowMultipleEmail, - AllowMultiplePhone = AllowMultiplePhone, - RequireUsernameIdentifier = RequireUsernameIdentifier, - RequireEmailVerification = RequireEmailVerification, - RequirePhoneVerification = RequirePhoneVerification, - AllowAdminOverride = AllowAdminOverride, - AllowUserOverride = AllowUserOverride + Behavior = Behavior.Clone(), + Validation = Validation.Clone(), + Normalization = Normalization.Clone(), + Uniqueness = Uniqueness.Clone() }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierUniquenessOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierUniquenessOptions.cs new file mode 100644 index 00000000..b4886616 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierUniquenessOptions.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options; + +// TODO(security): +// Validate identifier uniqueness against login identifier configuration. +// Identifier types used for direct login resolution should normally be tenant-unique unless a custom resolver explicitly guarantees +// unambiguous user resolution. +public sealed class UAuthIdentifierUniquenessOptions +{ + public UniquenessScope Username { get; set; } = UniquenessScope.Tenant; + public UniquenessScope Email { get; set; } = UniquenessScope.Tenant; + public UniquenessScope Phone { get; set; } = UniquenessScope.Tenant; + public UniquenessScope Custom { get; set; } = UniquenessScope.Tenant; + + internal UAuthIdentifierUniquenessOptions Clone() => new() + { + Username = Username, + Email = Email, + Phone = Phone, + Custom = Custom + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs index 07df6483..d94fbf3c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Options; public sealed class UAuthLoginIdentifierOptions @@ -18,10 +17,6 @@ public sealed class UAuthLoginIdentifierOptions public bool EnableCustomResolvers { get; set; } = true; public bool CustomResolversFirst { get; set; } = true; - public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new(); - - public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false; - internal UAuthLoginIdentifierOptions Clone() => new() { AllowedTypes = new HashSet(AllowedTypes), @@ -29,21 +24,5 @@ public sealed class UAuthLoginIdentifierOptions RequireVerificationForPhone = RequireVerificationForPhone, EnableCustomResolvers = EnableCustomResolvers, CustomResolversFirst = CustomResolversFirst, - EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers, - Normalization = Normalization.Clone() - }; -} - -public sealed class UAuthIdentifierNormalizationOptions -{ - public CaseHandling UsernameCase { get; set; } = CaseHandling.ToLower; - public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower; - public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve; - - internal UAuthIdentifierNormalizationOptions Clone() => new() - { - UsernameCase = UsernameCase, - EmailCase = EmailCase, - CustomCase = CustomCase }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs index 3e8ce27b..b66de941 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -6,7 +6,7 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { - if (!options.Identifiers.AllowAdminOverride && !options.Identifiers.AllowUserOverride) + if (!options.Identifiers.Behavior.AllowAdminOverride && !options.Identifiers.Behavior.AllowUserOverride) { return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + "At least one actor must be able to manage user identifiers."); diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 1a9db677..1445aada 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,9 @@ public static IServiceCollection AddUltimateAuthAuthenticationEntityFrameworkCor { services.AddDbContext(configureDb); } - + + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); return services; } diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs index cdab2eec..2020a87c 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Authentication.InMemory.Extensions; @@ -7,6 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthenticationInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.AddSingleton(); return services; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 8ed556c8..26739fbf 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; @@ -11,7 +12,9 @@ public static IServiceCollection AddUltimateAuthAuthorizationEntityFrameworkCore { services.AddDbContext(configureDb); } - + + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); services.AddScoped>(); return services; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs index 919b6a61..6af07653 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,6 +9,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs index 5dc2e15e..78075797 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -10,7 +10,7 @@ internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.Id); - public InMemoryRoleStore(TenantExecutionContext tenant) : base(tenant) + public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) { } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs index 7b5b2df3..b51d4f2a 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs @@ -1,14 +1,21 @@ using System.Collections.Concurrent; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; namespace CodeBeam.UltimateAuth.Authorization.InMemory; public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory { private readonly ConcurrentDictionary _stores = new(); + private readonly InMemoryAtomicContextAccessor _atomicContext; + + public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext) + { + _atomicContext = atomicContext; + } public IRoleStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext)); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs index 96dfd2bf..12d300e3 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -122,4 +122,9 @@ public interface IUserIdentifierClient /// Deletes an identifier of a specific user. /// Task DeleteUserAsync(UserKey userKey, DeleteUserIdentifierRequest request); + + /// + /// Checks the availability of a user identifier (e.g., email, username, phone) for registration or assignment. + /// + Task> CheckAvailabilityAsync(CheckUserIdentifierAvailabilityRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index fa240378..c81a8746 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -132,4 +132,10 @@ public async Task DeleteUserAsync(UserKey userKey, DeleteUserIdenti var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/delete"), request); return UAuthResultMapper.From(raw); } + + public async Task> CheckAvailabilityAsync(CheckUserIdentifierAvailabilityRequest request) + { + var raw = await _request.SendJsonAsync(Url("/users/identifiers/check-availability"), request); + return UAuthResultMapper.FromJson(raw); + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 7c390a43..2925ccb8 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,9 @@ public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(configureDb); } - + + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); return services; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs index 4cbd31b2..70e8a47d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -13,7 +13,7 @@ internal sealed class InMemoryPasswordCredentialStore : InMemoryTenantVersionedS protected override CredentialKey GetKey(PasswordCredential entity) => new(entity.Tenant, entity.Id); - public InMemoryPasswordCredentialStore(TenantExecutionContext tenant) : base(tenant) + public InMemoryPasswordCredentialStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) { } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs index fb48648d..e9b0de45 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; using System.Collections.Concurrent; namespace CodeBeam.UltimateAuth.Credentials.InMemory; @@ -7,9 +8,15 @@ namespace CodeBeam.UltimateAuth.Credentials.InMemory; public sealed class InMemoryPasswordCredentialStoreFactory : IPasswordCredentialStoreFactory { private readonly ConcurrentDictionary _stores = new(); + private readonly InMemoryAtomicContextAccessor _atomicContext; + + public InMemoryPasswordCredentialStoreFactory(InMemoryAtomicContextAccessor atomicContext) + { + _atomicContext = atomicContext; + } public IPasswordCredentialStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryPasswordCredentialStore(new TenantExecutionContext(t))); + return _stores.GetOrAdd(tenant, t => new InMemoryPasswordCredentialStore(new TenantExecutionContext(t), _atomicContext)); } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs index 9b53af9c..46acfd8f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -9,6 +10,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.TryAddSingleton(); return services; diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..dc0bd870 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthEntityFrameworkCore(this IServiceCollection services) where TDbContext : DbContext + { + services.TryAddScoped>(); + + return services; + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs new file mode 100644 index 00000000..4e8149d8 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs @@ -0,0 +1,55 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal sealed class EfCoreUAuthAtomicExecutor : IUAuthAtomicExecutor where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreUAuthAtomicExecutor(TDbContext db) + { + _db = db; + } + + public Task ExecuteAsync(Func operation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync( + async innerCt => + { + await operation(innerCt); + return null; + }, + ct); + } + + public async Task ExecuteAsync(Func> operation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(operation); + + // Participate in an already active transaction. + if (_db.Database.CurrentTransaction is not null) + { + return await operation(ct); + } + + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + try + { + var result = await operation(ct); + + await transaction.CommitAsync(ct); + + return result; + } + catch + { + await transaction.RollbackAsync(CancellationToken.None); + + throw; + } + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..af02c639 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthInMemoryInfrastructure(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContext.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContext.cs new file mode 100644 index 00000000..2acd2ea3 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContext.cs @@ -0,0 +1,36 @@ +namespace CodeBeam.UltimateAuth.InMemory; + +public sealed class InMemoryAtomicContext +{ + private readonly List _rollbackActions = new(); + + public void RegisterRollback(Action rollback) + { + ArgumentNullException.ThrowIfNull(rollback); + + _rollbackActions.Add(rollback); + } + + public void Rollback() + { + List? errors = null; + + for (var i = _rollbackActions.Count - 1; i >= 0; i--) + { + try + { + _rollbackActions[i](); + } + catch (Exception ex) + { + errors ??= new List(); + errors.Add(ex); + } + } + + if (errors is not null) + { + throw new AggregateException("One or more rollback operations failed.", errors); + } + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContextAccessor.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContextAccessor.cs new file mode 100644 index 00000000..63b53852 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContextAccessor.cs @@ -0,0 +1,12 @@ +namespace CodeBeam.UltimateAuth.InMemory; + +public sealed class InMemoryAtomicContextAccessor +{ + private readonly AsyncLocal _current = new(); + + public InMemoryAtomicContext? Current + { + get => _current.Value; + set => _current.Value = value; + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicCoordinator.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicCoordinator.cs new file mode 100644 index 00000000..ac7cad91 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicCoordinator.cs @@ -0,0 +1,6 @@ +namespace CodeBeam.UltimateAuth.InMemory; + +internal sealed class InMemoryAtomicCoordinator +{ + public SemaphoreSlim Gate { get; } = new(1, 1); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs index eb7edb5c..f428cd36 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs @@ -11,7 +11,7 @@ public abstract class InMemoryTenantVersionedStore : InMemoryVers { private readonly TenantExecutionContext _tenant; - protected InMemoryTenantVersionedStore(TenantExecutionContext tenant) + protected InMemoryTenantVersionedStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(atomicContext) { _tenant = tenant; } diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryUAuthAtomicExecutor.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryUAuthAtomicExecutor.cs new file mode 100644 index 00000000..f5437794 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryUAuthAtomicExecutor.cs @@ -0,0 +1,60 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.InMemory; + +internal sealed class InMemoryUAuthAtomicExecutor : IUAuthAtomicExecutor +{ + private readonly InMemoryAtomicCoordinator _coordinator; + private readonly InMemoryAtomicContextAccessor _contextAccessor; + + public InMemoryUAuthAtomicExecutor(InMemoryAtomicCoordinator coordinator, InMemoryAtomicContextAccessor contextAccessor) + { + _coordinator = coordinator; + _contextAccessor = contextAccessor; + } + + public Task ExecuteAsync(Func operation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync( + async innerCt => + { + await operation(innerCt); + return null; + }, + ct); + } + + public async Task ExecuteAsync(Func> operation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(operation); + + // Nested UltimateAuth operation participates in the + // currently active atomic operation. + if (_contextAccessor.Current is not null) + { + return await operation(ct); + } + + await _coordinator.Gate.WaitAsync(ct); + + var context = new InMemoryAtomicContext(); + _contextAccessor.Current = context; + + try + { + return await operation(ct); + } + catch + { + context.Rollback(); + throw; + } + finally + { + _contextAccessor.Current = null; + _coordinator.Gate.Release(); + } + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs index fc2df442..ede0594f 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs @@ -11,6 +11,13 @@ public abstract class InMemoryVersionedStore : IVersionedStore _store = new(); + private readonly InMemoryAtomicContextAccessor _atomicContext; + + protected InMemoryVersionedStore(InMemoryAtomicContextAccessor atomicContext) + { + _atomicContext = atomicContext; + } + protected abstract TKey GetKey(TEntity entity); protected virtual TEntity Snapshot(TEntity entity) => entity.Snapshot(); protected virtual void BeforeAdd(TEntity entity) { } @@ -53,6 +60,14 @@ public virtual Task AddAsync(TEntity entity, CancellationToken ct = default) if (!_store.TryAdd(key, snapshot)) throw new UAuthConflictException($"{typeof(TEntity).Name} already exists."); + if (_atomicContext.Current is { } atomic) + { + atomic.RegisterRollback(() => + { + _store.TryRemove(new KeyValuePair(key, snapshot)); + }); + } + return Task.CompletedTask; } @@ -78,6 +93,17 @@ public virtual Task SaveAsync(TEntity entity, long expectedVersion, Cancellation if (!_store.TryUpdate(key, next, current)) throw new UAuthConcurrencyException($"{typeof(TEntity).Name} update conflict."); + if (_atomicContext.Current is { } atomic) + { + atomic.RegisterRollback(() => + { + if (!_store.TryUpdate(key, current, next)) + { + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} rollback conflict."); + } + }); + } + return Task.CompletedTask; } @@ -98,6 +124,18 @@ public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTim if (!_store.TryRemove(new KeyValuePair(key, current))) throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + if (_atomicContext.Current is { } atomic) + { + atomic.RegisterRollback(() => + { + if (!_store.TryAdd(key, current)) + { + throw new UAuthConcurrencyException( + $"{typeof(TEntity).Name} rollback conflict."); + } + }); + } + return Task.CompletedTask; } @@ -112,6 +150,17 @@ public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTim if (!_store.TryUpdate(key, next, current)) throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + if (_atomicContext.Current is { } atomicSoft) + { + atomicSoft.RegisterRollback(() => + { + if (!_store.TryUpdate(key, current, next)) + { + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} rollback conflict."); + } + }); + } + return Task.CompletedTask; } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 2dc070af..3af269c2 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,9 @@ public static IServiceCollection AddUltimateAuthSessionsEntityFrameworkCore(configureDb); } - + + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); return services; diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs index adb0976c..8161e4a0 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Sessions.InMemory.Extensions; @@ -7,6 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthSessionsInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.AddSingleton(); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 11410052..ee52049b 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +13,9 @@ public static IServiceCollection AddUltimateAuthTokensEntityFrameworkCore(configureDb); } - + + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); return services; } diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs index 3b5868d4..aefb2574 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.InMemory.Extensions; using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tokens.InMemory.Extensions; @@ -7,6 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthTokensInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.AddSingleton(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInfo.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInfo.cs new file mode 100644 index 00000000..2025bb83 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInfo.cs @@ -0,0 +1,36 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserProfileInfo : IVersionedEntity +{ + public Guid Id { get; set; } + + public ProfileKey ProfileKey { get; set; } = ProfileKey.Default; + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? DisplayName { get; set; } + + public DateOnly? BirthDate { get; set; } + + public string? Gender { get; set; } + + public string? Bio { get; set; } + + public string? Language { get; set; } + + public string? TimeZone { get; set; } + + public string? Culture { get; set; } + + public IReadOnlyDictionary? Metadata { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CheckUserIdentifierAvailabilityRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CheckUserIdentifierAvailabilityRequest.cs new file mode 100644 index 00000000..f2f74f80 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CheckUserIdentifierAvailabilityRequest.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record CheckUserIdentifierAvailabilityRequest +{ + public required UserIdentifierType Type { get; init; } + public required string Value { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierAvailabilityResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierAvailabilityResult.cs new file mode 100644 index 00000000..de01e9e4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierAvailabilityResult.cs @@ -0,0 +1,39 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierAvailabilityResult +{ + public required bool IsValid { get; init; } + + public required bool IsAvailable { get; init; } + + public string? NormalizedValue { get; init; } + + public IReadOnlyList Errors { get; init; } = Array.Empty(); + + public static UserIdentifierAvailabilityResult Available(string normalizedValue) + => new() + { + IsValid = true, + IsAvailable = true, + NormalizedValue = normalizedValue + }; + + public static UserIdentifierAvailabilityResult Unavailable(string normalizedValue) + => new() + { + IsValid = true, + IsAvailable = false, + NormalizedValue = normalizedValue + }; + + public static UserIdentifierAvailabilityResult Invalid(IEnumerable errors, string? normalizedValue = null) + => new() + { + IsValid = false, + IsAvailable = false, + NormalizedValue = normalizedValue, + Errors = errors.ToArray() + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierValidationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierValidationResult.cs new file mode 100644 index 00000000..540abaea --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierValidationResult.cs @@ -0,0 +1,22 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserIdentifierValidationResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private UserIdentifierValidationResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static UserIdentifierValidationResult Success() + => new(true, Array.Empty()); + + public static UserIdentifierValidationResult Failed(IEnumerable errors) + => new(false, errors.ToList()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserProfileValidationResult.cs similarity index 55% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserProfileValidationResult.cs index f79173ef..74ea284d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserProfileValidationResult.cs @@ -2,21 +2,21 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class IdentifierValidationResult +public sealed class UserProfileValidationResult { public bool IsValid { get; } public IReadOnlyList Errors { get; } - private IdentifierValidationResult(bool isValid, IReadOnlyList errors) + private UserProfileValidationResult(bool isValid, IReadOnlyList errors) { IsValid = isValid; Errors = errors; } - public static IdentifierValidationResult Success() + public static UserProfileValidationResult Success() => new(true, Array.Empty()); - public static IdentifierValidationResult Failed(IEnumerable errors) + public static UserProfileValidationResult Failed(IEnumerable errors) => new(false, errors.ToList()); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index 3c9162ab..fdc5cee9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Users.Reference; +using CodeBeam.UltimateAuth.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -13,6 +14,8 @@ public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore(configureDb); } + services.AddUltimateAuthEntityFrameworkCore(); + services.AddScoped>(); services.AddScoped>(); services.AddScoped>(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index 2f87aab7..cac3ada2 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -153,7 +153,7 @@ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default var projection = entity.ToProjection(); - using var tx = await _db.Database.BeginTransactionAsync(ct); + //using var tx = await _db.Database.BeginTransactionAsync(ct); if (entity.IsPrimary) { @@ -172,7 +172,7 @@ await DbSet DbSet.Add(projection); await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + //await tx.CommitAsync(ct); } public async Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) @@ -182,7 +182,7 @@ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, Cancell if (entity.Tenant != _tenant) throw new UAuthConflictException("tenant_mismatch"); - using var tx = await _db.Database.BeginTransactionAsync(ct); + //using var tx = await _db.Database.BeginTransactionAsync(ct); if (entity.IsPrimary) { @@ -216,7 +216,7 @@ await DbSet existing.Version++; await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); + //await tx.CommitAsync(ct); } public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 72310f36..7e8527bb 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,6 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.InMemory.Extensions; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Users.InMemory.Extensions; @@ -11,6 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) { + services.AddUltimateAuthInMemoryInfrastructure(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 698836dd..25e10750 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -13,7 +13,7 @@ public sealed class InMemoryUserIdentifierStore : InMemoryTenantVersionedStore entity.Id; private readonly object _primaryLock = new(); - public InMemoryUserIdentifierStore(TenantExecutionContext tenant) : base(tenant) + public InMemoryUserIdentifierStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 0cd4633b..f3e37415 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -10,7 +10,7 @@ public sealed class InMemoryUserLifecycleStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.UserKey); - public InMemoryUserLifecycleStore(TenantExecutionContext tenant) : base(tenant) + public InMemoryUserLifecycleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 129d7e92..68cea827 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -13,7 +13,7 @@ public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.UserKey, entity.ProfileKey); - public InMemoryUserProfileStore(TenantExecutionContext tenant) : base(tenant) + public InMemoryUserProfileStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) { } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 5830a22d..127d31e7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -55,7 +55,9 @@ public UserProfile Snapshot() Language = Language, TimeZone = TimeZone, Culture = Culture, - Metadata = Metadata, + Metadata = Metadata is null + ? null + : new Dictionary(Metadata), CreatedAt = CreatedAt, UpdatedAt = UpdatedAt, DeletedAt = DeletedAt, @@ -212,6 +214,35 @@ public static UserProfile FromProjection( }; } + public UserProfileInfo ToDto() + { + return new UserProfileInfo + { + Id = Id, + ProfileKey = ProfileKey, + + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + + Metadata = Metadata is null + ? null + : new Dictionary(Metadata), + + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + Version = Version + }; + } + public UserProfile CloneTo( Guid? newId, ProfileKey newProfileKey, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 7ec8f92d..51865ec9 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -603,4 +603,20 @@ public async Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpC await _users.DeleteUserIdentifierAsync(accessContext, request, ctx.RequestAborted); return Results.Ok(); } + + public async Task CheckIdentifierAvailabilityAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.CheckAvailability, + resource: "users"); + + var result = await _users.CheckIdentifierAvailabilityAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index 2cfabc66..8dedbff7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -15,6 +15,7 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 035eead1..30a6dcb4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -34,6 +34,7 @@ public interface IUserApplicationService Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default); Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + Task CheckIdentifierAvailabilityAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default); Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs new file mode 100644 index 00000000..07f2e961 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserIdentifierAvailabilityService +{ + Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 252fe6d7..08ab97cc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -14,31 +14,36 @@ namespace CodeBeam.UltimateAuth.Users.Reference; internal sealed class UserApplicationService : IUserApplicationService { private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUAuthAtomicExecutor _atomicExecutor; private readonly IUserLifecycleStoreFactory _lifecycleStoreFactory; private readonly IUserIdentifierStoreFactory _identifierStoreFactory; private readonly IUserProfileStoreFactory _profileStoreFactory; private readonly IUserCreateValidator _userCreateValidator; - private readonly IIdentifierValidator _identifierValidator; + private readonly IUserIdentifierValidator _identifierValidator; private readonly IEnumerable _integrations; private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly IUserIdentifierAvailabilityService _identifierAvailabilityService; private readonly ISessionStoreFactory _sessionStoreFactory; private readonly UAuthServerOptions _options; private readonly IClock _clock; public UserApplicationService( IAccessOrchestrator accessOrchestrator, + IUAuthAtomicExecutor atomicExecutor, IUserLifecycleStoreFactory lifecycleStoreFactory, IUserIdentifierStoreFactory identifierStoreFactory, IUserProfileStoreFactory profileStoreFactory, IUserCreateValidator userCreateValidator, - IIdentifierValidator identifierValidator, + IUserIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, + IUserIdentifierAvailabilityService identifierAvailabilityService, ISessionStoreFactory sessionStoreFactory, IOptions options, IClock clock) { _accessOrchestrator = accessOrchestrator; + _atomicExecutor = atomicExecutor; _lifecycleStoreFactory = lifecycleStoreFactory; _identifierStoreFactory = identifierStoreFactory; _profileStoreFactory = profileStoreFactory; @@ -46,6 +51,7 @@ public UserApplicationService( _identifierValidator = identifierValidator; _integrations = integrations; _identifierNormalizer = identifierNormalizer; + _identifierAvailabilityService = identifierAvailabilityService; _sessionStoreFactory = sessionStoreFactory; _options = options.Value; _clock = clock; @@ -63,83 +69,90 @@ public async Task CreateUserAsync(AccessContext context, Creat throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); } - var now = _clock.UtcNow; - var userKey = UserKey.New(); - - var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); - await lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); - - var profileStore = _profileStoreFactory.Create(context.ResourceTenant); - await profileStore.AddAsync( - UserProfile.Create( - Guid.NewGuid(), - context.ResourceTenant, - userKey, - ProfileKey.Default, - now, - firstName: request.FirstName, - lastName: request.LastName, - displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, - birthDate: request.BirthDate, - gender: request.Gender, - bio: request.Bio, - language: request.Language, - timezone: request.TimeZone, - culture: request.Culture), innerCt); - - var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - if (!string.IsNullOrWhiteSpace(request.UserName)) + return await _atomicExecutor.ExecuteAsync( + async atomicCt => { - await identifierStore.AddAsync( - UserIdentifier.Create( - Guid.NewGuid(), - context.ResourceTenant, - userKey, - UserIdentifierType.Username, - request.UserName, - _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, - now, - true, - request.UserNameVerified ? now : null), innerCt); - } + var now = _clock.UtcNow; + var userKey = UserKey.New(); - if (!string.IsNullOrWhiteSpace(request.Email)) - { - await identifierStore.AddAsync( - UserIdentifier.Create( - Guid.NewGuid(), - context.ResourceTenant, - userKey, - UserIdentifierType.Email, - request.Email, - _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, - now, - true, - request.EmailVerified ? now : null), innerCt); - } + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + await lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), atomicCt); - if (!string.IsNullOrWhiteSpace(request.Phone)) - { - await identifierStore.AddAsync( - UserIdentifier.Create( + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + await profileStore.AddAsync( + UserProfile.Create( Guid.NewGuid(), context.ResourceTenant, userKey, - UserIdentifierType.Phone, - request.Phone, - _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + ProfileKey.Default, now, - true, - request.PhoneVerified ? now : null), innerCt); - } + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture), atomicCt); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Username, + request.UserName, + _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, + now, + true, + request.UserNameVerified ? now : null), atomicCt); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Email, + request.Email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, + now, + true, + request.EmailVerified ? now : null), atomicCt); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Phone, + request.Phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + now, + true, + request.PhoneVerified ? now : null), atomicCt); + } + + foreach (var integration in _integrations) + { + // Credential creation handle on here + await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, atomicCt); + } - foreach (var integration in _integrations) - { - // Credential creation handle on here - await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, innerCt); - } + return UserCreateResult.Success(userKey); + }, + innerCt); - return UserCreateResult.Success(userKey); + }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -516,25 +529,8 @@ public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifie if (userScopeResult.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); - var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || - (request.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(request.Type)); - - if (mustBeUnique) - { - var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers - ? IdentifierExistenceScope.TenantAny - : IdentifierExistenceScope.TenantPrimaryOnly; - - var globalResult = await identifierStore.ExistsAsync( - new IdentifierExistenceQuery( - request.Type, - normalized.Normalized, - scope), - innerCt); - - if (globalResult.Exists) - throw new UAuthIdentifierConflictException("identifier_already_exists"); - } + // TODO(policy): Move identifier uniqueness decision/enforcement to the Policy layer. + await EnsureIdentifierUniquenessAsync(identifierStore, request.Type, normalized.Normalized, userKey, excludeIdentifierId: null, innerCt); if (request.IsPrimary) { @@ -570,7 +566,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); - if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.AllowUsernameChange) + if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.Behavior.AllowUsernameChange) { throw new UAuthIdentifierValidationException("username_change_not_allowed"); } @@ -605,26 +601,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (withinUserResult.Exists) throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); - var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || - (identifier.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(identifier.Type)); - - if (mustBeUnique) - { - var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers - ? IdentifierExistenceScope.TenantAny - : IdentifierExistenceScope.TenantPrimaryOnly; - - var result = await identifierStore.ExistsAsync( - new IdentifierExistenceQuery( - identifier.Type, - normalized.Normalized, - scope, - ExcludeIdentifierId: identifier.Id), - innerCt); - - if (result.Exists) - throw new UAuthIdentifierConflictException("identifier_already_exists"); - } + await EnsureIdentifierUniquenessAsync(identifierStore, identifier.Type, normalized.Normalized, identifier.UserKey, excludeIdentifierId: identifier.Id, innerCt); var expectedVersion = identifier.Version; identifier.ChangeValue(request.NewValue, normalized.Normalized, _clock.UtcNow); @@ -643,7 +620,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.IsPrimary) @@ -651,12 +628,6 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); - var result = await identifierStore.ExistsAsync( - new IdentifierExistenceQuery(identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); - - if (result.Exists) - throw new UAuthIdentifierConflictException("identifier_already_exists"); - var expectedVersion = identifier.Version; identifier.SetPrimary(_clock.UtcNow); await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); @@ -739,7 +710,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde if (identifier.IsPrimary) throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); - if (_options.Identifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) + if (_options.Identifiers.Behavior.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) { var activeUsernames = identifiers .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) @@ -768,6 +739,13 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task CheckIdentifierAvailabilityAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(innerCt => _identifierAvailabilityService.CheckAsync(context, request, innerCt)); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + #endregion @@ -815,24 +793,24 @@ private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyL if (!hasSameType) return; - if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) + if (type == UserIdentifierType.Username && !_options.Identifiers.Behavior.AllowMultipleUsernames) throw new UAuthValidationException("multiple_usernames_not_allowed"); - if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) + if (type == UserIdentifierType.Email && !_options.Identifiers.Behavior.AllowMultipleEmail) throw new UAuthValidationException("multiple_emails_not_allowed"); - if (type == UserIdentifierType.Phone && !_options.Identifiers.AllowMultiplePhone) + if (type == UserIdentifierType.Phone && !_options.Identifiers.Behavior.AllowMultiplePhone) throw new UAuthValidationException("multiple_phones_not_allowed"); } private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) { - if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) + if (type == UserIdentifierType.Email && _options.Identifiers.Behavior.RequireEmailVerification && !isVerified) { throw new UAuthValidationException("email_verification_required"); } - if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) + if (type == UserIdentifierType.Phone && _options.Identifiers.Behavior.RequirePhoneVerification && !isVerified) { throw new UAuthValidationException("phone_verification_required"); } @@ -840,10 +818,10 @@ private void EnsureVerificationRequirements(UserIdentifierType type, bool isVeri private void EnsureOverrideAllowed(AccessContext context) { - if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) + if (context.IsSelfAction && !_options.Identifiers.Behavior.AllowUserOverride) throw new UAuthConflictException("user_override_not_allowed"); - if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) + if (!context.IsSelfAction && !_options.Identifiers.Behavior.AllowAdminOverride) throw new UAuthConflictException("admin_override_not_allowed"); } @@ -977,4 +955,56 @@ public async Task> QueryUsersAsync(AccessContext contex return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + + private async Task EnsureIdentifierUniquenessAsync( + IUserIdentifierStore store, + UserIdentifierType type, + string normalizedValue, + UserKey userKey, + Guid? excludeIdentifierId, + CancellationToken ct) + { + var scope = GetUniquenessScope(type); + + if (scope == UniquenessScope.None) + return; + + var existenceScope = scope switch + { + UniquenessScope.WithinUser => IdentifierExistenceScope.WithinUser, + + UniquenessScope.Tenant => IdentifierExistenceScope.TenantAny, + + _ => throw new UAuthValidationException("unsupported_identifier_uniqueness_scope") + }; + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + type, + normalizedValue, + existenceScope, + UserKey: scope == UniquenessScope.WithinUser + ? userKey + : null, + ExcludeIdentifierId: excludeIdentifierId), + ct); + + if (result.Exists) + { + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + } + + private UniquenessScope GetUniquenessScope(UserIdentifierType type) + { + var uniqueness = _options.Identifiers.Uniqueness; + + return type switch + { + UserIdentifierType.Username => uniqueness.Username, + UserIdentifierType.Email => uniqueness.Email, + UserIdentifierType.Phone => uniqueness.Phone, + _ => uniqueness.Custom + }; + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs new file mode 100644 index 00000000..19afc1ff --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -0,0 +1,58 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserIdentifierAvailabilityService : IUserIdentifierAvailabilityService +{ + private readonly IUserIdentifierValidator _validator; + private readonly IIdentifierNormalizer _normalizer; + private readonly IUserIdentifierStoreFactory _storeFactory; + + public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IIdentifierNormalizer normalizer, IUserIdentifierStoreFactory storeFactory) + { + _validator = validator; + _normalizer = normalizer; + _storeFactory = storeFactory; + } + + public async Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) + { + var identifier = new UserIdentifierInfo + { + Type = request.Type, + Value = request.Value + }; + + var validation = await _validator.ValidateAsync(context, identifier, ct); + + if (!validation.IsValid) + { + return UserIdentifierAvailabilityResult.Invalid(validation.Errors); + } + + var normalized = _normalizer.Normalize(request.Type, request.Value); + + if (!normalized.IsValid) + { + return UserIdentifierAvailabilityResult.Invalid( + new[] + { + new UAuthValidationError(normalized.ErrorCode ?? "identifier_invalid") + }); + } + + var store = _storeFactory.Create(context.ResourceTenant); + + var existence = await store.ExistsAsync( + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.TenantAny), + ct); + + return existence.Exists + ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) + : UserIdentifierAvailabilityResult.Available(normalized.Normalized); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj index 74729080..3871c097 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj @@ -11,6 +11,8 @@ + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs new file mode 100644 index 00000000..f14a31b0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; + +internal static class ServiceCollectionTestExtensions +{ + public static void DecorateForTest( + this IServiceCollection services, + Func decorator) + where TService : class + { + var descriptor = services.LastOrDefault( + x => x.ServiceType == typeof(TService)); + + if (descriptor is null) + { + throw new InvalidOperationException( + $"Service '{typeof(TService).FullName}' is not registered."); + } + + services.Remove(descriptor); + + services.Add( + ServiceDescriptor.Describe( + typeof(TService), + sp => + { + var inner = CreateInstance( + sp, + descriptor); + + return decorator(sp, inner); + }, + descriptor.Lifetime)); + } + + private static TService CreateInstance( + IServiceProvider serviceProvider, + ServiceDescriptor descriptor) + where TService : class + { + if (descriptor.ImplementationInstance is TService instance) + return instance; + + if (descriptor.ImplementationFactory is not null) + { + return (TService)descriptor + .ImplementationFactory(serviceProvider); + } + + if (descriptor.ImplementationType is not null) + { + return (TService)ActivatorUtilities.CreateInstance( + serviceProvider, + descriptor.ImplementationType); + } + + throw new InvalidOperationException( + $"Unable to construct decorated service '{typeof(TService).FullName}'."); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs similarity index 85% rename from tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs rename to tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs index d568093e..025b4dec 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs @@ -7,7 +7,6 @@ using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.AspNetCore.Hosting; @@ -15,11 +14,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace CodeBeam.UltimateAuth.Tests.Integration; +namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; public class AuthServerFactory : WebApplicationFactory { private readonly Action? _configureServer; + private readonly Action? _configureServices; public IntegrationTestClock Clock { get; } = new(); @@ -27,16 +27,29 @@ public AuthServerFactory() { } - private AuthServerFactory(Action configureServer) + private AuthServerFactory(Action? configureServer, Action? configureServices) { _configureServer = configureServer; + _configureServices = configureServices; + } + + public static AuthServerFactory CreateWithServices(Action configureServices) + { + ArgumentNullException.ThrowIfNull(configureServices); + + return new AuthServerFactory(configureServer: null, configureServices); } public static AuthServerFactory Create(Action configureServer) { ArgumentNullException.ThrowIfNull(configureServer); - return new AuthServerFactory(configureServer); + return new AuthServerFactory(configureServer, configureServices: null); + } + + public static AuthServerFactory Create(Action? configureServer, Action? configureServices) + { + return new AuthServerFactory(configureServer, configureServices); } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -52,6 +65,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { services.PostConfigure(options => _configureServer(options)); } + + _configureServices?.Invoke(services); }); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs new file mode 100644 index 00000000..82671d49 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +internal sealed class FailingUserIdentifierStore : IUserIdentifierStore +{ + private readonly IUserIdentifierStore _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStore(IUserIdentifierStore inner, UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public Task GetAsync( + Guid key, + CancellationToken ct = default) + => _inner.GetAsync(key, ct); + + public Task ExistsAsync( + Guid key, + CancellationToken ct = default) + => _inner.ExistsAsync(key, ct); + + public async Task AddAsync( + UserIdentifier entity, + CancellationToken ct = default) + { + if (_fault.ShouldFail(entity)) + { + throw new InvalidOperationException( + "simulated_identifier_store_failure"); + } + + await _inner.AddAsync(entity, ct); + } + + public Task SaveAsync( + UserIdentifier entity, + long expectedVersion, + CancellationToken ct = default) + => _inner.SaveAsync(entity, expectedVersion, ct); + + public Task DeleteAsync( + Guid key, + long expectedVersion, + DeleteMode deleteMode, + DateTimeOffset now, + CancellationToken ct = default) + => _inner.DeleteAsync( + key, + expectedVersion, + deleteMode, + now, + ct); + + public Task ExistsAsync( + IdentifierExistenceQuery query, + CancellationToken ct = default) + => _inner.ExistsAsync(query, ct); + + public Task> GetByUserAsync( + UserKey userKey, + CancellationToken ct = default) + => _inner.GetByUserAsync(userKey, ct); + + public Task GetByIdAsync( + Guid id, + CancellationToken ct = default) + => _inner.GetByIdAsync(id, ct); + + public Task GetAsync( + UserIdentifierType type, + string value, + CancellationToken ct = default) + => _inner.GetAsync(type, value, ct); + + public Task> QueryAsync( + UserIdentifierQuery query, + CancellationToken ct = default) + => _inner.QueryAsync(query, ct); + + public Task> GetByUsersAsync( + IReadOnlyList userKeys, + CancellationToken ct = default) + => _inner.GetByUsersAsync(userKeys, ct); + + public Task DeleteByUserAsync( + UserKey userKey, + DeleteMode mode, + DateTimeOffset deletedAt, + CancellationToken ct = default) + => _inner.DeleteByUserAsync( + userKey, + mode, + deletedAt, + ct); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStoreFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..7882f23a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStoreFactory.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Reference; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +internal sealed class FailingUserIdentifierStoreFactory + : IUserIdentifierStoreFactory +{ + private readonly IUserIdentifierStoreFactory _inner; + private readonly UserIdentifierStoreFaultState _fault; + + public FailingUserIdentifierStoreFactory( + IUserIdentifierStoreFactory inner, + UserIdentifierStoreFaultState fault) + { + _inner = inner; + _fault = fault; + } + + public IUserIdentifierStore Create(TenantKey tenant) + { + return new FailingUserIdentifierStore( + _inner.Create(tenant), + _fault); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs new file mode 100644 index 00000000..f4a33983 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs @@ -0,0 +1,93 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Helpers; + +internal static class TestAccessContext +{ + public static AccessContext WithAction(string action) + { + return new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.Single, + isAuthenticated: false, + isSystemActor: false, + actorChainId: null, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUser( + UserKey userKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForTargetUser( + UserKey actorUserKey, + UserKey targetUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUserCreation( + UserKey actorUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: "users", + targetUserKey: null, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestUsers.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestUsers.cs new file mode 100644 index 00000000..c07b6472 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestUsers.cs @@ -0,0 +1,9 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Helpers; + +public static class TestUsers +{ + public static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + public static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/UserIdentifierStoreFaultState.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/UserIdentifierStoreFaultState.cs new file mode 100644 index 00000000..7576fe1d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/UserIdentifierStoreFaultState.cs @@ -0,0 +1,51 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; + +internal sealed class UserIdentifierStoreFaultState +{ + private int _addAttempts; + + public bool Enabled { get; private set; } + + public int FailOnAddAttempt { get; private set; } + + public int AddAttempts => _addAttempts; + + public UserIdentifierType? LastAttemptedType { get; private set; } + + public UserKey? LastAttemptedUserKey { get; private set; } + + public void Enable(int failOnAddAttempt) + { + if (failOnAddAttempt <= 0) + throw new ArgumentOutOfRangeException(nameof(failOnAddAttempt)); + + _addAttempts = 0; + LastAttemptedType = null; + LastAttemptedUserKey = null; + + FailOnAddAttempt = failOnAddAttempt; + Enabled = true; + } + + public void Disable() + { + Enabled = false; + } + + public bool ShouldFail(UserIdentifier identifier) + { + if (!Enabled) + return false; + + var attempt = Interlocked.Increment(ref _addAttempts); + + LastAttemptedType = identifier.Type; + LastAttemptedUserKey = identifier.UserKey; + + return attempt == FailOnAddAttempt; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index dfc3fc41..17b92bff 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs index 3185e136..bd77b124 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs index 4a90896f..3896b0e8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs index 6dd6dee3..94834b9b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs index 050ddcc1..474ec758 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs new file mode 100644 index 00000000..322ba131 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs @@ -0,0 +1,118 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Helpers; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class UserCreationAtomicityTests +{ + [Fact] + public async Task CreateUser_WhenPersistenceFails_ShouldRollbackAllUserState() + { + var fault = new UserIdentifierStoreFaultState(); + + using var factory = AuthServerFactory.CreateWithServices( + services => + { + services.AddSingleton(fault); + + services.DecorateForTest( + (sp, inner) => + new FailingUserIdentifierStoreFactory(inner, sp.GetRequiredService())); + }); + + // Force host initialization before accessing stores. + _ = factory.Services; + + fault.Enable(failOnAddAttempt: 2); + + using var scope = factory.Services.CreateScope(); + + var service = scope.ServiceProvider.GetRequiredService(); + + var lifecycleFactory = + scope.ServiceProvider + .GetRequiredService(); + + var profileFactory = + scope.ServiceProvider + .GetRequiredService(); + + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); + + var username = + $"atomic-{Guid.NewGuid():N}"; + + var email = + $"atomic-{Guid.NewGuid():N}@example.com"; + + var context = TestAccessContext.ForUserCreation(TestUsers.Admin, UAuthActions.Users.CreateAdmin); + + var request = new CreateUserRequest + { + UserName = username, + Email = email, + + FirstName = "Atomic", + LastName = "Failure" + }; + + // + // Current implementation is expected to throw when + // the second identifier is persisted. + // + var exception = await Assert.ThrowsAsync( + () => service.CreateUserAsync( + context, + request)); + + exception.Message.Should() + .Be("simulated_identifier_store_failure"); + + // + // Verify that the intended failure actually happened + // at the expected persistence point. + // + fault.AddAttempts.Should().Be(2); + + fault.LastAttemptedType + .Should().Be(UserIdentifierType.Email); + + fault.LastAttemptedUserKey + .Should().NotBeNull(); + + var userKey = + fault.LastAttemptedUserKey!.Value; + + var tenant = + context.ResourceTenant; + + // + // ATOMICITY CONTRACT + var lifecycleStore = lifecycleFactory.Create(tenant); + + var profileStore = profileFactory.Create(tenant); + + var identifierStore = identifierFactory.Create(tenant); + + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); + + var profiles = await profileStore.GetAllProfilesByUserAsync(userKey); + + var identifiers = await identifierStore.GetByUserAsync(userKey); + + lifecycle.Should().BeNull("failed user creation must not leave a lifecycle aggregate"); + + identifiers.Should().BeEmpty("failed user creation must not leave identifiers"); + + profiles.Should().BeEmpty("failed user creation must not leave user profiles"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs new file mode 100644 index 00000000..505f0083 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -0,0 +1,218 @@ +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class UserIdentifierTests : IClassFixture +{ + private readonly AuthServerFactory _factory; + + public UserIdentifierTests(AuthServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Availability_WhenUsernameDoesNotExist_ShouldReturnAvailable() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = $"available-{Guid.NewGuid():N}"; + + var response = await client.PostAsJsonAsync("/auth/me/identifiers/check-availability", + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = username + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeTrue(); + result.Errors.Should().BeEmpty(); + result.NormalizedValue.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Availability_WhenUsernameAlreadyExists_ShouldReturnUnavailable() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = $"taken-{Guid.NewGuid():N}"; + + var create = await client.PostAsJsonAsync("/auth/users/create", + new CreateUserRequest + { + UserName = username, + Password = $"Test-{Guid.NewGuid():N}!", + DisplayName = username + }); + + create.StatusCode.Should().Be(HttpStatusCode.OK); + + var response = await client.PostAsJsonAsync( + "/auth/me/identifiers/check-availability", + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = username + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeFalse(); + result.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task Availability_ShouldUseNormalizedIdentifier() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var unique = Guid.NewGuid().ToString("N"); + + var email = $"availability-{unique}@example.com"; + + var create = await client.PostAsJsonAsync("/auth/users/create", + new CreateUserRequest + { + UserName = $"user-{unique}", + Email = email, + Password = $"Test-{Guid.NewGuid():N}!", + DisplayName = "Availability Test" + }); + + create.StatusCode.Should().Be(HttpStatusCode.OK); + + // Same logical identifier, different representation. + var response = await client.PostAsJsonAsync( + "/auth/me/identifiers/check-availability", + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = $" {email.ToUpperInvariant()} " + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeFalse(); + result.Errors.Should().BeEmpty(); + + result.NormalizedValue.Should().Be(email.ToLowerInvariant()); + } + + [Fact] + public async Task Availability_WhenIdentifierIsInvalid_ShouldReturnValidationResult() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var response = await client.PostAsJsonAsync( + "/auth/me/identifiers/check-availability", + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = "not-an-email" + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var result = + await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.IsValid.Should().BeFalse(); + result.IsAvailable.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + [Fact] + public async Task Availability_WhenIdentifierIsEmpty_ShouldReturnValidationResult() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var response = await client.PostAsJsonAsync( + "/auth/me/identifiers/check-availability", + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = " " + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var result = + await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.IsValid.Should().BeFalse(); + result.IsAvailable.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + // TODO: + // Expand UserIdentifier integration coverage: + // + // - Add identifier (self/admin) + // - Update identifier (self/admin) + // - Delete identifier (self/admin) + // - Set/unset primary + // - Verify identifier + // - Duplicate identifier policies + // - Username/email/phone uniqueness policies + // - Tenant isolation + // - Soft-deleted identifier availability semantics + // - Authorization / endpoint permission checks + // - Optimistic concurrency + // - Multi-profile interactions if identifier ownership evolves + // - Client SDK end-to-end coverage + + private HttpClient CreateClient() + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", $"user-identifier-{Guid.NewGuid():N}"); + + return client; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index e418ec43..b89955c0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs index 4859ea70..eac094f6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserProfileTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs index e5029507..5bc8596b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Authorization.InMemory; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization; @@ -13,16 +14,17 @@ protected override Task CreateDatabaseAsync() new InMemoryRoleStoreTestDatabase()); } - private sealed class InMemoryRoleStoreTestDatabase - : IRoleStoreTestDatabase + private sealed class InMemoryRoleStoreTestDatabase : IRoleStoreTestDatabase { + private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + public IRoleStore CreateStore(TenantKey tenant) { - var executionContext = - new TenantExecutionContext(tenant); + var executionContext = new TenantExecutionContext(tenant); return new InMemoryRoleStore( - executionContext); + executionContext, + _atomicContext); } public ValueTask DisposeAsync() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs index 6f37cae2..a30ef290 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs @@ -2,24 +2,30 @@ using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.InMemory; using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; -public sealed class InMemoryPasswordCredentialStoreContractTests - : PasswordCredentialStoreContractTests +public sealed class InMemoryPasswordCredentialStoreContractTests : PasswordCredentialStoreContractTests { - protected override Task - CreateDatabaseAsync() + protected override TaskCreateDatabaseAsync() { return Task.FromResult( new Database()); } private sealed class Database - : IPasswordCredentialStoreTestDatabase + : IPasswordCredentialStoreTestDatabase { - private readonly InMemoryPasswordCredentialStoreFactory _factory = - new(); + private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + + private readonly InMemoryPasswordCredentialStoreFactory _factory; + + public Database() + { + _factory = new InMemoryPasswordCredentialStoreFactory( + _atomicContext); + } public IPasswordCredentialStore CreateStore(TenantKey tenant) => _factory.Create(tenant); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index bb66a0e9..b602f544 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -179,5 +179,38 @@ public async Task LoginAsync(AuthFlowContext flow) }); } + public async Task AddIdentifierAsync(UserKey userKey, UserIdentifierType type, string value, TenantKey? tenant = null, + bool isPrimary = true, bool isVerified = true, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + var services = scope.ServiceProvider; + var identifierFactory = services.GetRequiredService(); + var normalizer = services.GetRequiredService(); + + var effectiveTenant = tenant ?? TenantKeys.Single; + var now = Clock.UtcNow; + + var normalized = normalizer.Normalize(type, value); + + if (!normalized.IsValid) + throw new InvalidOperationException($"Test identifier could not be normalized: {normalized.ErrorCode}"); + + var identifier = UserIdentifier.Create( + id: Guid.NewGuid(), + tenant: effectiveTenant, + userKey: userKey, + type: type, + value: value, + normalizedValue: normalized.Normalized, + now: now, + isPrimary: isPrimary, + verifiedAt: isVerified ? now : null); + + var store = identifierFactory.Create(effectiveTenant); + await store.AddAsync(identifier, ct); + + return identifier; + } + internal sealed record TestLoginUser(UserKey UserKey, string Identifier, string Secret); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 90b20a6f..fe385ea1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -243,8 +243,8 @@ public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() services.AddOptions() .Configure(o => { - o.Identifiers.AllowAdminOverride = false; - o.Identifiers.AllowUserOverride = false; + o.Identifiers.Behavior.AllowAdminOverride = false; + o.Identifiers.Behavior.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); @@ -268,14 +268,14 @@ public void UserIdentifiers_at_least_one_override_enabled_should_pass() services.AddOptions() .Configure(o => { - o.Identifiers.AllowAdminOverride = true; - o.Identifiers.AllowUserOverride = false; + o.Identifiers.Behavior.AllowAdminOverride = true; + o.Identifiers.Behavior.AllowUserOverride = false; }); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); var provider = services.BuildServiceProvider(); var options = provider.GetRequiredService>().Value; - options.Identifiers.AllowAdminOverride.Should().BeTrue(); + options.Identifiers.Behavior.AllowAdminOverride.Should().BeTrue(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 7644b744..18f5b5f5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.InMemory; @@ -13,7 +14,7 @@ public class IdentifierConcurrencyTests [Fact] public async Task Save_should_increment_version() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -34,7 +35,7 @@ public async Task Save_should_increment_version() [Fact] public async Task Delete_should_throw_when_version_conflicts() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -57,7 +58,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_SetPrimary_should_conflict_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var id = Guid.NewGuid(); @@ -103,7 +104,7 @@ public async Task Parallel_SetPrimary_should_conflict_deterministic() [Fact] public async Task Update_should_throw_concurrency_when_versions_conflict() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var id = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -130,7 +131,7 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Parallel_updates_should_result_in_single_success_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -181,7 +182,7 @@ public async Task Parallel_updates_should_result_in_single_success_deterministic [Fact] public async Task High_contention_updates_should_allow_only_one_success() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var id = Guid.NewGuid(); @@ -227,7 +228,7 @@ public async Task High_contention_updates_should_allow_only_one_success() [Fact] public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; @@ -276,7 +277,7 @@ public async Task High_contention_SetPrimary_should_allow_only_one_deterministic [Fact] public async Task Two_identifiers_racing_for_primary_should_allow() { - var store = new InMemoryUserIdentifierStore(new TenantExecutionContext(TenantKeys.Single)); + var store = CreateStore(); var now = DateTimeOffset.UtcNow; var tenant = TenantKey.Single; var user = TestUsers.Admin; @@ -352,4 +353,11 @@ public async Task Two_identifiers_racing_for_primary_should_allow() Assert.Single(primaries); } + + private static InMemoryUserIdentifierStore CreateStore() + { + return new InMemoryUserIdentifierStore( + new TenantExecutionContext(TenantKeys.Single), + new InMemoryAtomicContextAccessor()); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs index 0ecec14f..e3f1deb1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs @@ -1,11 +1,10 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.Reference; -namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; - -public sealed class InMemoryUserIdentifierStoreContractTests - : UserIdentifierStoreContractTests +public sealed class InMemoryUserIdentifierStoreContractTests : UserIdentifierStoreContractTests { protected override Task CreateDatabaseAsync() @@ -18,13 +17,16 @@ private sealed class Database : IUserIdentifierStoreTestDatabase { private readonly Dictionary _stores = []; + private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + public IUserIdentifierStore CreateStore(TenantKey tenant) { - if (_stores.TryGetValue(tenant, out var store)) - return store; + if (_stores.TryGetValue(tenant, out var existing)) + return existing; - store = new InMemoryUserIdentifierStore( - new TenantExecutionContext(tenant)); + var store = new InMemoryUserIdentifierStore( + new TenantExecutionContext(tenant), + _atomicContext); _stores.Add(tenant, store); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs index 367f4d25..aec76080 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs @@ -1,11 +1,11 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; -public sealed class InMemoryUserLifecycleStoreContractTests - : UserLifecycleStoreContractTests +public sealed class InMemoryUserLifecycleStoreContractTests : UserLifecycleStoreContractTests { protected override Task CreateDatabaseAsync() @@ -18,13 +18,16 @@ private sealed class Database : IUserLifecycleStoreTestDatabase { private readonly Dictionary _stores = []; + private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + public IUserLifecycleStore CreateStore(TenantKey tenant) { if (_stores.TryGetValue(tenant, out var existing)) return existing; var store = new InMemoryUserLifecycleStore( - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), + _atomicContext); _stores.Add(tenant, store); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs index 0fd7f8ca..e9046745 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs @@ -1,12 +1,11 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; -public sealed class InMemoryUserProfileStoreContractTests - : UserProfileStoreContractTests +public sealed class InMemoryUserProfileStoreContractTests : UserProfileStoreContractTests { protected override Task CreateDatabaseAsync() @@ -19,13 +18,16 @@ private sealed class Database : IUserProfileStoreTestDatabase { private readonly Dictionary _stores = []; + private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + public IUserProfileStore CreateStore(TenantKey tenant) { if (_stores.TryGetValue(tenant, out var existing)) return existing; var store = new InMemoryUserProfileStore( - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), + _atomicContext); _stores.Add(tenant, store); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 6ff3b1f4..3ed0280a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1122,13 +1122,25 @@ public async Task UpdateUserIdentifierAsync_ValidatesNewValue_NotExistingValue() It.IsAny())) .ReturnsAsync(identifier); + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Email && + q.NormalizedValue == "new@example.com" && + q.Scope == IdentifierExistenceScope.TenantAny && + q.UserKey == null && + q.ExcludeIdentifierId == identifier.Id), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult( + Exists: false)); + f.IdentifierValidator .Setup(x => x.ValidateAsync( context, It.Is(x => x.Value == "new@example.com"), It.IsAny())) - .ReturnsAsync(IdentifierValidationResult.Success()); + .ReturnsAsync(UserIdentifierValidationResult.Success()); f.IdentifierNormalizer .Setup(x => x.Normalize( @@ -1209,18 +1221,6 @@ public async Task SetPrimaryUserIdentifierAsync_WhenValid_SetsPrimaryAndSavesExp It.IsAny())) .ReturnsAsync(identifier); - f.IdentifierStore - .Setup(x => x.ExistsAsync( - It.Is(q => - q.Type == UserIdentifierType.Email && - q.NormalizedValue == "alice@example.com" && - q.Scope == IdentifierExistenceScope.TenantPrimaryOnly && - q.UserKey == null && - q.ExcludeIdentifierId == identifier.Id), - It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult( - Exists: false)); - f.IdentifierStore .Setup(x => x.SaveAsync( identifier, @@ -1243,6 +1243,8 @@ await f.Sut.SetPrimaryUserIdentifierAsync( 5, It.IsAny()), Times.Once); + + f.IdentifierStore.Verify(x => x.ExistsAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -1536,50 +1538,25 @@ await f.Sut.DeleteUserAsync( // Helpers // ============================================================ - private static Fixture CreateFixture( - params IUserLifecycleIntegration[] integrations) + private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrations) { - var access = - new Mock(MockBehavior.Strict); - - var lifecycleFactory = - new Mock(MockBehavior.Strict); - - var identifierFactory = - new Mock(MockBehavior.Strict); - - var profileFactory = - new Mock(MockBehavior.Strict); - - var lifecycleStore = - new Mock(MockBehavior.Strict); - - var identifierStore = - new Mock(MockBehavior.Strict); - - var profileStore = - new Mock(MockBehavior.Strict); - - var validator = - new Mock(MockBehavior.Strict); - - var identifierValidator = - new Mock(MockBehavior.Strict); - - var normalizer = - new Mock(MockBehavior.Strict); - - var sessionFactory = - new Mock(MockBehavior.Strict); - - var sessionStore = - new Mock(MockBehavior.Strict); - - var clock = - new Mock(MockBehavior.Strict); - - clock.SetupGet(x => x.UtcNow) - .Returns(Now); + var access = new Mock(MockBehavior.Strict); + var atomic = new Mock(MockBehavior.Strict); + var lifecycleFactory = new Mock(MockBehavior.Strict); + var identifierFactory = new Mock(MockBehavior.Strict); + var profileFactory = new Mock(MockBehavior.Strict); + var lifecycleStore = new Mock(MockBehavior.Strict); + var identifierStore = new Mock(MockBehavior.Strict); + var profileStore = new Mock(MockBehavior.Strict); + var validator = new Mock(MockBehavior.Strict); + var identifierValidator = new Mock(MockBehavior.Strict); + var normalizer = new Mock(MockBehavior.Strict); + var identifierAvailability = new Mock(MockBehavior.Strict); + var sessionFactory = new Mock(MockBehavior.Strict); + var sessionStore = new Mock(MockBehavior.Strict); + var clock = new Mock(MockBehavior.Strict); + + clock.SetupGet(x => x.UtcNow).Returns(Now); lifecycleFactory .Setup(x => x.Create(It.IsAny())) @@ -1617,11 +1594,19 @@ private static Fixture CreateFixture( .Returns, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); + atomic + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (operation, ct) => operation(ct)); + var options = Options.Create( new UAuthServerOptions()); var sut = new UserApplicationService( access.Object, + atomic.Object, lifecycleFactory.Object, identifierFactory.Object, profileFactory.Object, @@ -1629,6 +1614,7 @@ private static Fixture CreateFixture( identifierValidator.Object, integrations, normalizer.Object, + identifierAvailability.Object, sessionFactory.Object, options, clock.Object); @@ -1636,6 +1622,7 @@ private static Fixture CreateFixture( return new Fixture( sut, access, + atomic, lifecycleStore, identifierStore, profileStore, @@ -1801,11 +1788,12 @@ private static void SetupCreateNormalizers( private sealed record Fixture( UserApplicationService Sut, Mock Access, + Mock Atomic, Mock LifecycleStore, Mock IdentifierStore, Mock ProfileStore, Mock UserCreateValidator, - Mock IdentifierValidator, + Mock IdentifierValidator, Mock IdentifierNormalizer, Mock SessionStore); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 7122902a..ae12ee5d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -122,15 +122,25 @@ await service.UpdateUserIdentifierAsync(context, } [Fact] - public async Task Non_primary_duplicate_should_be_allowed_when_global_uniqueness_disabled() + public async Task Duplicate_email_should_be_allowed_when_uniqueness_is_none() { - var runtime = new TestAuthRuntime(); + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.Uniqueness.Email = UniquenessScope.None; + }); + var service = runtime.GetUserApplicationService(); - var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); - var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + var user1 = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.AddSelf); + + var user2 = TestAccessContext.ForUser( + TestUsers.Admin, + UserIdentifiers.AddSelf); - await service.AddUserIdentifierAsync(user1, + await service.AddUserIdentifierAsync( + user1, new AddUserIdentifierRequest { Type = UserIdentifierType.Email, @@ -138,48 +148,95 @@ await service.AddUserIdentifierAsync(user1, IsPrimary = false }); - await service.AddUserIdentifierAsync(user2, + Func act = () => + service.AddUserIdentifierAsync( + user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Add_email_should_fail_when_tenant_uniqueness_is_enabled() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.Uniqueness.Email = UniquenessScope.Tenant; + }); + + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.AddSelf); + + var user2 = TestAccessContext.ForUser( + TestUsers.Admin, + UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync( + user1, new AddUserIdentifierRequest { Type = UserIdentifierType.Email, - Value = "shared@example.com", - IsPrimary = false + Value = "unique@example.com" }); - true.Should().BeTrue(); // no exception + Func act = () => + service.AddUserIdentifierAsync( + user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com" + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_already_exists*"); } [Fact] - public async Task Primary_duplicate_should_fail_when_global_uniqueness_enabled() + public async Task Add_email_should_succeed_across_users_when_uniqueness_is_none() { var runtime = new TestAuthRuntime(configureServer: o => { - o.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers = true; + o.Identifiers.Uniqueness.Email = UniquenessScope.None; }); var service = runtime.GetUserApplicationService(); - var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); - var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + var user1 = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.AddSelf); + + var user2 = TestAccessContext.ForUser( + TestUsers.Admin, + UserIdentifiers.AddSelf); - await service.AddUserIdentifierAsync(user1, + await service.AddUserIdentifierAsync( + user1, new AddUserIdentifierRequest { Type = UserIdentifierType.Email, - Value = "unique@example.com", - IsPrimary = true + Value = "shared@example.com" }); - Func act = async () => - await service.AddUserIdentifierAsync(user2, + Func act = () => + service.AddUserIdentifierAsync( + user2, new AddUserIdentifierRequest { Type = UserIdentifierType.Email, - Value = "unique@example.com", - IsPrimary = true + Value = "shared@example.com" }); - await act.Should().ThrowAsync(); + await act.Should().NotThrowAsync(); } [Fact] @@ -240,7 +297,7 @@ public async Task Username_should_respect_case_policy() { var runtime = new TestAuthRuntime(configureServer: o => { - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Behavior.AllowMultipleUsernames = true; }); var service = runtime.GetUserApplicationService(); @@ -264,8 +321,8 @@ public async Task Username_should_be_case_insensitive_when_configured() { var runtime = new TestAuthRuntime(configureServer: o => { - o.LoginIdentifiers.Normalization.UsernameCase = CaseHandling.ToLower; - o.Identifiers.AllowMultipleUsernames = true; + o.Identifiers.Normalization.UsernameCase = CaseHandling.ToLower; + o.Identifiers.Behavior.AllowMultipleUsernames = true; }); var service = runtime.GetUserApplicationService(); @@ -347,6 +404,141 @@ await service.UpdateUserIdentifierAsync(context, await act.Should().ThrowAsync(); } + [Fact] + public async Task Availability_should_return_available_for_unused_identifier() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.CheckAvailability); + + var result = await service.CheckIdentifierAvailabilityAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Username, + Value = "unused-user-name" + }); + + result.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeTrue(); + } + + [Fact] + public async Task Availability_should_return_unavailable_for_existing_identifier() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.CheckAvailability); + + var identifiers = + await service.GetIdentifiersByUserAsync( + context, + new UserIdentifierQuery()); + + var existing = identifiers.Items.First(); + + var result = await service.CheckIdentifierAvailabilityAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = existing.Type, + Value = existing.Value + }); + + result.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeFalse(); + } + + [Fact] + public async Task Availability_should_use_normalized_identifier() + { + var runtime = new TestAuthRuntime(); + + var user = await runtime.CreateLoginUserAsync(); + + const string storedEmail = "availability@example.com"; + + await runtime.AddIdentifierAsync(user.UserKey, UserIdentifierType.Email, storedEmail); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(user.UserKey, UserIdentifiers.CheckAvailability); + + var result = await service.CheckIdentifierAvailabilityAsync(context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = " AVAILABILITY@EXAMPLE.COM " + }); + + result.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeFalse(); + result.NormalizedValue.Should().Be("availability@example.com"); + } + + [Fact] + public async Task Availability_should_return_validation_errors_for_invalid_identifier() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + UserIdentifiers.CheckAvailability); + + var result = await service.CheckIdentifierAvailabilityAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = "invalid" + }); + + result.IsValid.Should().BeFalse(); + result.IsAvailable.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + [Fact] + public async Task Email_availability_should_be_case_insensitive_by_default() + { + var runtime = new TestAuthRuntime(); + + var user = + await runtime.CreateLoginUserAsync(); + + await runtime.AddIdentifierAsync( + user.UserKey, + UserIdentifierType.Email, + "availability@example.com"); + + var service = + runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser( + user.UserKey, + UserIdentifiers.CheckAvailability); + + var result = + await service.CheckIdentifierAvailabilityAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = UserIdentifierType.Email, + Value = "AVAILABILITY@EXAMPLE.COM" + }); + + result.IsValid.Should().BeTrue(); + result.IsAvailable.Should().BeFalse(); + result.NormalizedValue.Should().Be("availability@example.com"); + } + //[Fact] //public async Task Same_identifier_in_different_tenants_should_not_conflict() //{