From de8b7a6d6e5ec388e2681e3d2837c2099f29f0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 30 Sep 2026 10:38:33 +0300 Subject: [PATCH 1/5] Added UserProfileValidator --- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Validator/IIdentifierValidator.cs | 9 ----- .../Validator/IUserIdentifierValidator.cs | 9 +++++ .../Validator/IUserProfileValidator.cs | 9 +++++ .../Validator/UserCreateValidator.cs | 30 ++++++++++++++-- ...alidator.cs => UserIdentifierValidator.cs} | 12 +++---- .../Validator/UserProfileValidator.cs | 15 ++++++++ .../Dtos/UserProfileInfo.cs | 36 +++++++++++++++++++ .../UserIdentifierValidationResult.cs | 22 ++++++++++++ ...sult.cs => UserProfileValidationResult.cs} | 8 ++--- .../Domain/UserProfile.cs | 33 ++++++++++++++++- .../Services/UserApplicationService.cs | 4 +-- .../Users/UserApplicationServiceTests.cs | 6 ++-- 13 files changed, 168 insertions(+), 28 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserIdentifierValidator.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IUserProfileValidator.cs rename src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/{IdentifierValidator.cs => UserIdentifierValidator.cs} (83%) create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserProfileInfo.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierValidationResult.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/{IdentifierValidationResult.cs => UserProfileValidationResult.cs} (55%) 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/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..e647c19d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs @@ -0,0 +1,15 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +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/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/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.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/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 252fe6d7..bb1c1228 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -18,7 +18,7 @@ internal sealed class UserApplicationService : IUserApplicationService 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 ISessionStoreFactory _sessionStoreFactory; @@ -31,7 +31,7 @@ public UserApplicationService( IUserIdentifierStoreFactory identifierStoreFactory, IUserProfileStoreFactory profileStoreFactory, IUserCreateValidator userCreateValidator, - IIdentifierValidator identifierValidator, + IUserIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, ISessionStoreFactory sessionStoreFactory, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 6ff3b1f4..eb61f27a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1128,7 +1128,7 @@ public async Task UpdateUserIdentifierAsync_ValidatesNewValue_NotExistingValue() It.Is(x => x.Value == "new@example.com"), It.IsAny())) - .ReturnsAsync(IdentifierValidationResult.Success()); + .ReturnsAsync(UserIdentifierValidationResult.Success()); f.IdentifierNormalizer .Setup(x => x.Normalize( @@ -1564,7 +1564,7 @@ private static Fixture CreateFixture( new Mock(MockBehavior.Strict); var identifierValidator = - new Mock(MockBehavior.Strict); + new Mock(MockBehavior.Strict); var normalizer = new Mock(MockBehavior.Strict); @@ -1805,7 +1805,7 @@ private sealed record Fixture( Mock IdentifierStore, Mock ProfileStore, Mock UserCreateValidator, - Mock IdentifierValidator, + Mock IdentifierValidator, Mock IdentifierNormalizer, Mock SessionStore); From 61ca681bf324ae7753d990a5cb02d8faaeb1a35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 3 Oct 2026 23:37:37 +0300 Subject: [PATCH 2/5] Check User Identifier Available --- .../Defaults/UAuthActions.cs | 1 + .../Abstractions/IUserEndpointHandler.cs | 1 + .../Endpoints/UAuthEndpointRegistrar.cs | 4 + .../Validator/UserProfileValidator.cs | 1 - .../Abstractions/IUserIdentifierClient.cs | 5 + .../Services/UAuthUserIdentifierClient.cs | 6 + .../CheckUserIdentifierAvailabilityRequest.cs | 7 + .../UserIdentifierAvailabilityResult.cs | 39 ++++ .../Endpoints/UserEndpointHandler.cs | 16 ++ .../Services/IUserApplicationService.cs | 1 + .../IUserIdentifierAvailabilityService.cs | 9 + .../Services/UserApplicationService.cs | 41 ++++ .../UserIdentifierAvailabilityService.cs | 52 +++++ .../UserIdentifierTests.cs | 217 ++++++++++++++++++ .../Helpers/TestAuthRuntime.cs | 33 +++ .../UserIdentifierApplicationServiceTests.cs | 135 +++++++++++ 16 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CheckUserIdentifierAvailabilityRequest.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierAvailabilityResult.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserIdentifierAvailabilityService.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs 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/Infrastructure/Validator/UserProfileValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs index e647c19d..7d9ac809 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserProfileValidator.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure; 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/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.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/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 bb1c1228..29168631 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -768,6 +768,47 @@ 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( + async innerCt => + { + var identifier = new UserIdentifierInfo + { + Type = request.Type, + Value = request.Value + }; + + var validation = await _identifierValidator.ValidateAsync(context, identifier, innerCt); + + if (!validation.IsValid) + { + return UserIdentifierAvailabilityResult.Invalid(validation.Errors); + } + + var normalized = _identifierNormalizer.Normalize(request.Type, request.Value); + + if (!normalized.IsValid) + { + return UserIdentifierAvailabilityResult.Invalid( + new[] + { + new UAuthValidationError(normalized.ErrorCode ?? "identifier_invalid") + }); + } + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + + var existence = await identifierStore.ExistsAsync(new IdentifierExistenceQuery(request.Type, normalized.Normalized, IdentifierExistenceScope.TenantAny), innerCt); + + return existence.Exists + ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) + : UserIdentifierAvailabilityResult.Available(normalized.Normalized); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + #endregion 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..ef79ef4a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -0,0 +1,52 @@ +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 info = new UserIdentifierInfo + { + Type = request.Type, + Value = request.Value + }; + + var validation = await _validator.ValidateAsync(context, info, ct); + + if (!validation.IsValid) + { + return new UserIdentifierAvailabilityResult + { + IsValid = false, + IsAvailable = false, + Errors = validation.Errors + }; + } + + var normalized = _normalizer.Normalize(request.Type, request.Value).Normalized; + + var store = _storeFactory.Create(context.ResourceTenant); + + var existence = await store.ExistsAsync(new IdentifierExistenceQuery(request.Type, normalized, IdentifierExistenceScope.TenantAny), ct); + + return new UserIdentifierAvailabilityResult + { + IsValid = true, + IsAvailable = !existence.Exists, + NormalizedValue = normalized + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs new file mode 100644 index 00000000..4668c081 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -0,0 +1,217 @@ +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.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/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 7122902a..433a7aee 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -347,6 +347,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() //{ From b1a821c94b653994583d62aec9942aa8e0053134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 4 Oct 2026 01:06:17 +0300 Subject: [PATCH 3/5] User Identifier Uniqueness --- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Contracts/Common/UniquenessScope.cs | 8 + .../Normalizer/IdentifierNormalizer.cs | 2 +- .../Options/UAuthIdentifierBehaviorOptions.cs | 31 ++++ .../UAuthIdentifierNormalizationOptions.cs | 17 ++ .../Options/UAuthIdentifierOptions.cs | 27 +-- .../UAuthIdentifierUniquenessOptions.cs | 23 +++ .../Options/UAuthLoginIdentifierOptions.cs | 23 +-- ...uthServerUserIdentifierOptionsValidator.cs | 2 +- .../Extensions/ServiceCollectonExtensions.cs | 1 + .../Services/UserApplicationService.cs | 159 ++++++++---------- .../UserIdentifierAvailabilityService.cs | 38 +++-- .../Server/ServerOptionsValidatorTests.cs | 10 +- .../Users/UserApplicationServiceTests.cs | 87 ++++------ .../UserIdentifierApplicationServiceTests.cs | 105 +++++++++--- 18 files changed, 305 insertions(+), 236 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierBehaviorOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierNormalizationOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierUniquenessOptions.cs 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/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/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/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/Contracts/Common/UniquenessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs new file mode 100644 index 00000000..d3435442 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum UniquenessScope +{ + None = 0, + WithinUser = 10, + Tenant = 20 +} 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/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/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/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 29168631..591144fd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -21,6 +21,7 @@ internal sealed class UserApplicationService : IUserApplicationService 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; @@ -34,6 +35,7 @@ public UserApplicationService( IUserIdentifierValidator identifierValidator, IEnumerable integrations, IIdentifierNormalizer identifierNormalizer, + IUserIdentifierAvailabilityService identifierAvailabilityService, ISessionStoreFactory sessionStoreFactory, IOptions options, IClock clock) @@ -46,6 +48,7 @@ public UserApplicationService( _identifierValidator = identifierValidator; _integrations = integrations; _identifierNormalizer = identifierNormalizer; + _identifierAvailabilityService = identifierAvailabilityService; _sessionStoreFactory = sessionStoreFactory; _options = options.Value; _clock = clock; @@ -516,25 +519,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 +556,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 +591,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 +610,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 +618,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 +700,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) @@ -770,41 +731,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde public async Task CheckIdentifierAvailabilityAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) { - var command = new AccessCommand( - async innerCt => - { - var identifier = new UserIdentifierInfo - { - Type = request.Type, - Value = request.Value - }; - - var validation = await _identifierValidator.ValidateAsync(context, identifier, innerCt); - - if (!validation.IsValid) - { - return UserIdentifierAvailabilityResult.Invalid(validation.Errors); - } - - var normalized = _identifierNormalizer.Normalize(request.Type, request.Value); - - if (!normalized.IsValid) - { - return UserIdentifierAvailabilityResult.Invalid( - new[] - { - new UAuthValidationError(normalized.ErrorCode ?? "identifier_invalid") - }); - } - - var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - - var existence = await identifierStore.ExistsAsync(new IdentifierExistenceQuery(request.Type, normalized.Normalized, IdentifierExistenceScope.TenantAny), innerCt); - - return existence.Exists - ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) - : UserIdentifierAvailabilityResult.Available(normalized.Normalized); - }); + var command = new AccessCommand(innerCt => _identifierAvailabilityService.CheckAsync(context, request, innerCt)); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } @@ -856,24 +783,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"); } @@ -881,10 +808,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"); } @@ -1018,4 +945,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 index ef79ef4a..19afc1ff 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -18,35 +18,41 @@ public UserIdentifierAvailabilityService(IUserIdentifierValidator validator, IId public async Task CheckAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default) { - var info = new UserIdentifierInfo + var identifier = new UserIdentifierInfo { Type = request.Type, Value = request.Value }; - var validation = await _validator.ValidateAsync(context, info, ct); + var validation = await _validator.ValidateAsync(context, identifier, ct); if (!validation.IsValid) { - return new UserIdentifierAvailabilityResult - { - IsValid = false, - IsAvailable = false, - Errors = validation.Errors - }; + return UserIdentifierAvailabilityResult.Invalid(validation.Errors); } - var normalized = _normalizer.Normalize(request.Type, request.Value).Normalized; + 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, IdentifierExistenceScope.TenantAny), ct); + var existence = await store.ExistsAsync( + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + IdentifierExistenceScope.TenantAny), + ct); - return new UserIdentifierAvailabilityResult - { - IsValid = true, - IsAvailable = !existence.Exists, - NormalizedValue = normalized - }; + return existence.Exists + ? UserIdentifierAvailabilityResult.Unavailable(normalized.Normalized) + : UserIdentifierAvailabilityResult.Available(normalized.Normalized); } } 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/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index eb61f27a..faa33c13 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1122,6 +1122,18 @@ 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, @@ -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,24 @@ 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 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())) @@ -1629,6 +1605,7 @@ private static Fixture CreateFixture( identifierValidator.Object, integrations, normalizer.Object, + identifierAvailability.Object, sessionFactory.Object, options, clock.Object); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs index 433a7aee..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); - await service.AddUserIdentifierAsync(user1, + var user2 = TestAccessContext.ForUser( + TestUsers.Admin, + UserIdentifiers.AddSelf); + + 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(); From 7c6a23f851836dcc89f28da9c9c9f767d12348ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 4 Oct 2026 20:19:42 +0300 Subject: [PATCH 4/5] Atomic User Create --- ...=> 20261004164613_001_Initial.Designer.cs} | 11 +- ...eAuth.cs => 20261004164613_001_Initial.cs} | 10 +- .../Migrations/UAuthDbContextModelSnapshot.cs | 7 +- .../uauthhub.db | Bin 4096 -> 4096 bytes .../uauthhub.db-shm | Bin 32768 -> 32768 bytes .../uauthhub.db-wal | Bin 1133032 -> 902312 bytes ...=> 20261004164018_001_Initial.Designer.cs} | 11 +- ...eAuth.cs => 20261004164018_001_Initial.cs} | 10 +- .../Migrations/UAuthDbContextModelSnapshot.cs | 7 +- .../uauth.db | Bin 4096 -> 4096 bytes .../uauth.db-shm | Bin 32768 -> 32768 bytes .../uauth.db-wal | Bin 914672 -> 1013552 bytes .../Abstractions/IUAuthAtomicExecutor.cs | 8 + .../AssemblyVisibility.cs | 1 + .../Contracts/Common/UniquenessScope.cs | 16 ++ .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../ServiceCollectionExtensions.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 7 +- .../Extensions/ServiceCollectionExtensions.cs | 3 + .../Stores/InMemoryRoleStore.cs | 2 +- .../Stores/InMemoryRoleStoreFactory.cs | 9 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../InMemoryPasswordCredentialStore.cs | 2 +- .../InMemoryPasswordCredentialStoreFactory.cs | 9 +- .../ServiceCollectionExtensions.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 16 ++ .../EfCoreUAuthAtomicExecutor.cs | 55 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 17 ++ .../InMemoryAtomicContext.cs | 36 +++++ .../InMemoryAtomicContextAccessor.cs | 12 ++ .../InMemoryAtomicCoordinator.cs | 6 + .../InMemoryTenantVersionedStore.cs | 2 +- .../InMemoryUAuthAtomicExecutor.cs | 60 +++++++ .../InMemoryVersionedStore.cs | 49 ++++++ .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../ServiceCollectionExtensions.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../ServiceCollectionExtensions.cs | 3 + .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Stores/EfCoreUserIdentifierStore.cs | 8 +- .../Extensions/ServiceCollectionExtensions.cs | 7 +- .../Stores/InMemoryUserIdentifierStore.cs | 2 +- .../Stores/InMemoryUserLifecycleStore.cs | 2 +- .../Stores/InMemoryUserProfileStore.cs | 2 +- .../Services/UserApplicationService.cs | 146 ++++++++++-------- ...Beam.UltimateAuth.Tests.Integration.csproj | 2 + .../ServiceCollectionTestExtensions.cs | 61 ++++++++ .../{ => Infrastructure}/AuthServerFactory.cs | 23 ++- .../FailingUserIdentifierStore.cs | 103 ++++++++++++ .../FailingUserIdentifierStoreFactory.cs | 30 ++++ .../Infrastructure/TestAccessContext.cs | 93 +++++++++++ .../Infrastructure/TestUsers.cs | 9 ++ .../UserIdentifierStoreFaultState.cs | 51 ++++++ .../LoginTests.cs | 1 + .../LogoutAdminTests.cs | 1 + .../LogoutTests.cs | 1 + .../SessionAdminTests.cs | 1 + .../SessionTests.cs | 1 + .../UserCreationAtomicityTests.cs | 118 ++++++++++++++ .../UserIdentifierTests.cs | 3 +- .../UserLifecycleTests.cs | 1 + .../UserProfileTests.cs | 3 +- .../Store/InMemoryRoleStoreContractTests.cs | 12 +- ...oryPasswordCredentialStoreContractTests.cs | 20 ++- .../Users/IdentifierConcurrencyTests.cs | 24 ++- ...nMemoryUserIdentifierStoreContractTests.cs | 18 ++- ...InMemoryUserLifecycleStoreContractTests.cs | 9 +- .../InMemoryUserProfileStoreContractTests.cs | 10 +- .../Users/UserApplicationServiceTests.cs | 11 ++ 69 files changed, 1022 insertions(+), 154 deletions(-) rename samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/{20260412205559_InitUltimateAuth.Designer.cs => 20261004164613_001_Initial.Designer.cs} (98%) rename samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/{20260412205559_InitUltimateAuth.cs => 20261004164613_001_Initial.cs} (98%) rename samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/{20260406192328_InitUltimateAuth.Designer.cs => 20261004164018_001_Initial.Designer.cs} (98%) rename samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/{20260406192328_InitUltimateAuth.cs => 20261004164018_001_Initial.cs} (98%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthAtomicExecutor.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/EfCoreUAuthAtomicExecutor.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContext.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicContextAccessor.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryAtomicCoordinator.cs create mode 100644 src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryUAuthAtomicExecutor.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Extensions/ServiceCollectionTestExtensions.cs rename tests/CodeBeam.UltimateAuth.Tests.Integration/{ => Infrastructure}/AuthServerFactory.cs (85%) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStoreFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestUsers.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/UserIdentifierStoreFaultState.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/UserCreationAtomicityTests.cs 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/uauthhub.db b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/uauthhub.db index 4e86411b5803e34b1e4767ce981907694f15c1ed..c9cf4b4e788deb2a37a4a5eef5db1c380729a4e1 100644 GIT binary patch delta 22 ccmZorXi%7t#4+_WF9Q$=Y%J{OpJ>1i08G>dKmY&$ delta 22 ccmZorXi%7t#L=e7%K!ud8w>mSCmOH=07DlAj{pDw 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 274b15c6cfab2fa0da1255837b1300c217392e53..86e2b19dd355eb82f885b92ef11315ef44fbc91e 100644 GIT binary patch delta 921 zcmbu6+e=hY6vn@G_Bn(Pc2QEZi&>1CX!g>DtR56}tVXbgq#~3GjL^iiGRBx;o{BQ0 z)x~ah5t$B3Wv3f-W6F96x{R5b5{T3vP>(%SYoFso5A_gj_}FWIzqP)-IVTb^A`v5; zF;NJ;GDQSTACo;GDz)vT=jrX$WexRJpYyuAD!%M2DXcM0|7*WQxl8B%{B2Fz{ow%l z^nHeCrYKERAxalzh}Mc`h*J0~r6&3Re#+MDzY@sLaU+WdjG%A5Wa(GU66r7V$1|^1 z#oVU+RLXHx(W$6I&yyT6Y{JUEYE1#G8vT>;TY&&lG3ZEid7uZ*gjxF;h$ zYAHrttmY#wMvPmkqN*SFUAPQm9?Zs57w!#4`CIihwHy~bumI0ANDiPDb=ZRtqBtT> zvz3gaI3ZP!T7iphGcDwJjcrw86Pg5s)Kny7k2#%Nhe>Tli#Dxkc#J`*daRY`!c`|> z`iOk|WV_9~YopYuC#+&zLbr>o9NfkO-OsyrMCq)?Y83_q=8~0*J9wz!lnscX7dNCn zXrP%oa!-`TpZ3|#;pPHzZXL)YO6QQ@`%NWy>E#uo182Ov(N-@{Eyg<+@URF|`g zn7xZu@yuy+SeTg)$RQOaIiJ1AA(Z=m-afSH3-|VWc3s#1fB*02uswFW$!<5REji- zrmrAakSquhgbJnz0tA6vP9bV9mG!@Naiu9cCPn1R=jT zEDX^huK;V#}oBhf{X z8iqN@VwZA6OTrDjgo#9x$DN6lmV5XBPq&T2IcuJ+*gVX8GH&7(en6oh>C9D4P%w)r z$!V#_M|gF|T9J+%8B59ByvD{I$(5G-aG{S|_lUq@IB`rk4(oYSI4#fAMxzN|;M;vK zk+_5wIp>@?4rMb6Wvz_Ce%+UOBOq5k0Fi66|}mgSiZQZqS$O#1F;tegnEPvGtKna zP}LzgiF58U2>WnQ>S_Y6qRkl6PI z1~QS0d^ki-slI7-94Xtd(;XGUk8&zJ5^)_*(J4mJqz=b+>@rkt%B#|z;TyUTAiqHb yX(MnOZ()&L6mdg-mHD~&6&Rg15_j+p9_~)R>LsXD7!(Q&G+r|eLIiCA6@gSdh7R|^>+D7 zxoOQzwMJbcn8Ly@w`Z|rEaqb#o)=s1yK-sQxPn0w≶VkJ#vQ)9(GdQ?bnI@>0TU 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 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/uauth.db b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db index 4e86411b5803e34b1e4767ce981907694f15c1ed..c9cf4b4e788deb2a37a4a5eef5db1c380729a4e1 100644 GIT binary patch delta 22 ccmZorXi%7t#4+_WF9Q$=Y%J{OpJ>1i08G>dKmY&$ delta 22 ccmZorXi%7t#L=e7%K!ud8w>mSCmOH=07DlAj{pDw 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 1c694283171adb6b6d4ac8d607e9879c9933e66d..9f70b715236f9a65c34cbf1b8ccc03fdd59cac73 100644 GIT binary patch delta 967 zcmbu7Sx8i26vzK(?py{RB-7MsBTPoKM&F`9%Z+r(#-c)d+_dZ>r!z>oVs=lsw4zI(YIkICaPxvhPX z&|)G(fZs4bolMbiVM<)-x9KBQwblvk-S!IWffa52ExG^d=T&-Wd%yn{Kg-Q-C)u>~ zOiUmX#LQ)a87ni78O`{MK??A3{5=cNdjGY79OANRl4v!h+2->u?N(!%am*icF-wUS z>lH^}B0I)2+Bb}4;+e!>T}qT>cE-pmF!q(BTF+*;~pOKh8kJ9D2nJc%X(3e<`qL3jmiw{lVlDj%Vb`x_;W&x&cz{05Vp)U+9L7=BC8|I6pqf>^D98gr2-$4*^}p5i7N)TL}%*GHS<3y%y&5FtaoX+O#O#wy6I1+!ig|)Paxto$vddKb*t0ESF`u!tT!* zu-7b>0H%!acQxzWft-Sg{+7|NU$&$*`F!8q!N!ywo0{wXmFH1<*>ivWwf@At;X;w( z;>~ncBFoE4V!2r>SyNf@)Gm_bI4=3WPEofjkXpcV?R(B(&18+cryL`b$_FTHZ3>Zs?yBMqJUYcE@r0-qXd4cFwe_8^kJA?Nd3$m5ELk@WoCiQ$4iXR{uNyztWS?y&md9T2huQC#A%L)EO>;G+J5PTErxnQ$35a3B3#w5zr?i-dS( zAEfHF5#tJO+FrF5bNpJz!IFq^5cRe<7UQ(4Fg-%f!6Uq}BUy;`2(SyuI2=NQ#+IPj z!RDeDx9mt1Q7k{KzLi-n>MN|3lkosC8rvK-TF}mHs*tI;irY3z+sh-OkzG_~q7`SI zIt>bUbiHkBM4Z%h79QiRQ+sd?cO4s#CY;e^eU&K&oNykl?q5 O$bK6uY>JQULcam!c>B=+ 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 b5380fc80a8a159a31f3efece8fb0ae39bc5e748..9f63271774146adb23c75c9e808ba5aaed583609 100644 GIT binary patch delta 17241 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 ktfA137DZwI 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 index d3435442..0ab50a03 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UniquenessScope.cs @@ -1,8 +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/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/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.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/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 591144fd..08ab97cc 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -14,6 +14,7 @@ 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; @@ -28,6 +29,7 @@ internal sealed class UserApplicationService : IUserApplicationService public UserApplicationService( IAccessOrchestrator accessOrchestrator, + IUAuthAtomicExecutor atomicExecutor, IUserLifecycleStoreFactory lifecycleStoreFactory, IUserIdentifierStoreFactory identifierStoreFactory, IUserProfileStoreFactory profileStoreFactory, @@ -41,6 +43,7 @@ public UserApplicationService( IClock clock) { _accessOrchestrator = accessOrchestrator; + _atomicExecutor = atomicExecutor; _lifecycleStoreFactory = lifecycleStoreFactory; _identifierStoreFactory = identifierStoreFactory; _profileStoreFactory = profileStoreFactory; @@ -66,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); 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 index 4668c081..505f0083 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.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.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/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 faa33c13..3ed0280a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1541,6 +1541,7 @@ await f.Sut.DeleteUserAsync( private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrations) { 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); @@ -1593,11 +1594,19 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat .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, @@ -1613,6 +1622,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat return new Fixture( sut, access, + atomic, lifecycleStore, identifierStore, profileStore, @@ -1778,6 +1788,7 @@ private static void SetupCreateNormalizers( private sealed record Fixture( UserApplicationService Sut, Mock Access, + Mock Atomic, Mock LifecycleStore, Mock IdentifierStore, Mock ProfileStore, From 2df30e1e6dd805a6b31f739a684273f38a10c099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 4 Oct 2026 22:08:44 +0300 Subject: [PATCH 5/5] Readme Arrangement --- .github/PULL_REQUEST_TEMPLATE.md | 8 ++++---- README.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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 |