From 8ebab5510507854ec60ca7454ec15d642c0145c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 8 Oct 2026 23:37:06 +0300 Subject: [PATCH 01/26] fix(credentials): prevent cross-user credential reset --- .../Program.cs | 1 + .../Contracts/Authority/AccessContext.cs | 4 + .../Services/CredentialManagementService.cs | 10 +- .../CredentialManagementTests.cs | 495 ++++++++++++++++++ .../CredentialManagementServiceTests.cs | 233 ++++++++- 5 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index d26cbda0..6acf2d76 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddUltimateAuthSampleSeed(); +// Customize UltimateAuth if (builder.Environment.IsDevelopment()) { builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 7fa62828..0ea44e81 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -27,6 +27,10 @@ public sealed class AccessContext public bool HasActor => ActorUserKey != null; public bool HasTarget => TargetUserKey != null; + public bool IsAdminContext => Action.EndsWith(".admin", StringComparison.Ordinal); + public bool IsSelfContext => Action.EndsWith(".self", StringComparison.Ordinal); + public bool IsAnonymousContext => Action.EndsWith(".anonymous", StringComparison.Ordinal); + public UserKey GetTargetUserKey() { if (TargetUserKey is not UserKey targetUserKey) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 95735d6e..db3982ec 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -219,9 +219,11 @@ public async Task BeginResetAsync(AccessContext cont ExpiresAt = expiresAt }; - var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + if (context.IsAdminContext && resolution?.UserKey != context.GetTargetUserKey()) + throw new UAuthValidationException("credential_target_mismatch"); + if (resolution?.UserKey is not UserKey userKey) return result; // Enumeration protection @@ -283,14 +285,16 @@ public async Task CompleteResetAsync(AccessContext conte var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + if (context.IsAdminContext && resolution?.UserKey != context.GetTargetUserKey()) + throw new UAuthValidationException("credential_target_mismatch"); + if (resolution?.UserKey is not UserKey userKey) { // Enumeration protection return CredentialActionResult.Success(); } - var state = await _authenticationSecurityManager - .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); if (!state.HasActiveReset(now)) throw new UAuthConflictException("reset_request_not_active"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs new file mode 100644 index 00000000..e5846a5d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -0,0 +1,495 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class CredentialManagementTests +{ + [Fact] + public async Task AnonymousReset_WithValidToken_ChangesPassword() + { + var notifier = new TestResetNotifier(); + + using var factory = AuthServerFactory.CreateWithServices(services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var beginResponse = await client.PostAsJsonAsync( + "/auth/me/credentials/reset/begin", + new BeginResetCredentialRequest + { + Identifier = user.Identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + beginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var notification = notifier.Notifications + .Should() + .ContainSingle(x => x.UserKey == user.UserKey) + .Subject; + + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + + var completeResponse = await client.PostAsJsonAsync( + "/auth/me/credentials/reset/complete", + new CompleteResetCredentialRequest + { + Identifier = user.Identifier, + CredentialType = CredentialType.Password, + ResetToken = notification.Token, + NewSecret = newPassword + }); + + completeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // Credential'ın gerçekten değiştiğini doğrula. + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var hasher = scope.ServiceProvider + .GetRequiredService(); + + var store = storeFactory.Create(notification.Tenant); + + var credentials = await store.GetByUserAsync(user.UserKey); + + var password = credentials + .OfType() + .Single(); + + hasher.Verify(password.SecretHash, newPassword) + .Should().BeTrue(); + + hasher.Verify(password.SecretHash, user.Secret) + .Should().BeFalse(); + } + + + [Fact] + public async Task AnonymousBeginReset_UnknownIdentifier_ShouldNotRevealUser() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + using var client = CreateClient(factory); + + var response = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + $"unknown-{Guid.NewGuid():N}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task AdminBeginReset_TargetMismatch_ShouldRejectWithoutNotification() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Credentials.BeginResetAdmin]); + + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + + var response = await BeginResetAsync( + client, + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", + other.Identifier); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + notifier.Notifications.Should().BeEmpty(); + + await AssertPasswordAsync(factory, other, other.Secret); + } + + [Fact] + public async Task AdminBeginReset_ValidTarget_ShouldNotifyTarget() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Credentials.BeginResetAdmin]); + + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + + var response = await BeginResetAsync( + client, + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", + target.Identifier); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + notifier.Notifications.Should() + .ContainSingle(x => x.UserKey == target.UserKey); + } + + [Fact] + public async Task AdminCompleteReset_TargetMismatch_ShouldNotChangeOtherPassword() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Credentials.CompleteResetAdmin]); + + // B için gerçekten geçerli bir reset token'ı üret. + using var anonymousClient = CreateClient(factory); + + var begin = await BeginResetAsync(anonymousClient, "/auth/me/credentials/reset/begin", other.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == other.UserKey) + .Token; + + using var adminClient = CreateClient(factory); + await AuthenticateAsync(adminClient, admin); + + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + + var response = await CompleteResetAsync( + adminClient, + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/complete", + other.Identifier, + token, + newPassword); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + await AssertPasswordAsync(factory, other, other.Secret); + await AssertPasswordRejectedAsync(factory, other, newPassword); + } + + [Fact] + public async Task AdminCompleteReset_ValidTarget_ShouldChangePassword() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Credentials.BeginResetAdmin, + UAuthActions.Credentials.CompleteResetAdmin + ]); + + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + + var url = + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; + + var begin = await BeginResetAsync( + client, + $"{url}/begin", + target.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var token = notifier.Notifications + .Single(x => x.UserKey == target.UserKey) + .Token; + + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + + var complete = await CompleteResetAsync( + client, + $"{url}/complete", + target.Identifier, + token, + newPassword); + + complete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + await AssertPasswordAsync(factory, target, newPassword); + await AssertPasswordRejectedAsync(factory, target, target.Secret); + } + + [Fact] + public async Task AnonymousCompleteReset_ReusedToken_ShouldReject() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var begin = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications.Single().Token; + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + var replayPassword = $"Replay-{Guid.NewGuid():N}!"; + + var first = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + newPassword); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var replay = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + replayPassword); + + replay.IsSuccessStatusCode.Should().BeFalse(); + + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, replayPassword); + } + + [Fact] + public async Task AdminBeginReset_WithoutPermission_ShouldNotNotify() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var actor = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + await AuthenticateAsync(client, actor); + + var response = await BeginResetAsync( + client, + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", + target.Identifier); + + response.IsSuccessStatusCode.Should().BeFalse(); + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task AdminBeginReset_Unauthenticated_ShouldReject() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var target = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var response = await BeginResetAsync( + client, + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", + target.Identifier); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + notifier.Notifications.Should().BeEmpty(); + } + + + + private static HttpClient CreateClient(AuthServerFactory factory) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + $"credential-integration-{Guid.NewGuid():N}"); + + return client; + } + + private sealed class TestResetNotifier : ICredentialResetNotifier + { + private readonly ConcurrentQueue + _notifications = new(); + + public IReadOnlyList Notifications => + _notifications.ToArray(); + + public Task NotifyAsync( + CredentialResetNotification notification, + CancellationToken ct = default) + { + _notifications.Enqueue(notification); + return Task.CompletedTask; + } + } + + + private static AuthServerFactory CreateFactory(TestResetNotifier notifier) + { + return AuthServerFactory.CreateWithServices(services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + } + + private static Task BeginResetAsync(HttpClient client, string url, string identifier) + { + return client.PostAsJsonAsync( + url, + new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + } + + private static Task CompleteResetAsync(HttpClient client, string url, string identifier, string token, string newPassword) + { + return client.PostAsJsonAsync( + url, + new CompleteResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetToken = token, + NewSecret = newPassword + }); + } + + private static async Task AuthenticateAsync(HttpClient client, IntegrationTestUser user) + { + var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers.TryGetValues( + "Set-Cookie", + out var values).Should().BeTrue(); + + var cookie = values! + .First(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)) + .Split(';', 2)[0]; + + client.DefaultRequestHeaders.Add("Cookie", cookie); + } + + private static async Task AssertPasswordAsync(AuthServerFactory factory, IntegrationTestUser user, string expectedPassword) + { + var credential = await GetPasswordCredentialAsync(factory, user); + + using var scope = factory.Services.CreateScope(); + + var hasher = scope.ServiceProvider + .GetRequiredService(); + + hasher.Verify(credential.SecretHash, expectedPassword) + .Should().BeTrue(); + } + + private static async Task AssertPasswordRejectedAsync(AuthServerFactory factory, IntegrationTestUser user, string password) + { + var credential = await GetPasswordCredentialAsync(factory, user); + + using var scope = factory.Services.CreateScope(); + + var hasher = scope.ServiceProvider + .GetRequiredService(); + + hasher.Verify(credential.SecretHash, password) + .Should().BeFalse(); + } + + private static async Task GetPasswordCredentialAsync(AuthServerFactory factory, IntegrationTestUser user) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = storeFactory.Create(TenantKeys.Single); + + var credentials = await store.GetByUserAsync(user.UserKey); + + return credentials + .OfType() + .Single(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index b6f2bf97..70b485d3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -8,11 +8,10 @@ using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; -using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; -using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.Extensions.Options; using Moq; @@ -652,6 +651,225 @@ public async Task DeleteAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFou Times.Never); } + + // --------------------------------------------------------- + // BeginReset / CompleteReset - Target Authorization + // --------------------------------------------------------- + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BeginResetAsync_Admin_WhenIdentifierDoesNotMatchTarget_RejectsWithoutMutation( + bool identifierExists) + { + var f = CreateFixture(); + var actor = UserKey.New(); + var target = UserKey.New(); + var other = UserKey.New(); + const string identifier = "other@example.com"; + + var context = TestAccessContext.ForTargetUser( + actor, + target, + UAuthActions.Credentials.BeginResetAdmin); + + f.IdentifierResolver + .Setup(x => x.ResolveAsync( + context.ResourceTenant, + identifier, + It.IsAny())) + .ReturnsAsync(identifierExists + ? CreateResolution(context, identifier, other) + : null); + + var act = () => f.Sut.BeginResetAsync( + context, + new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should() + .Be("credential_target_mismatch"); + + f.SecurityManager.Verify( + x => x.GetOrCreateFactorAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.TokenGenerator.Verify( + x => x.Generate(), + Times.Never); + + f.ResetNotifier.Verify( + x => x.NotifyAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompleteResetAsync_Admin_WhenIdentifierDoesNotMatchTarget_RejectsWithoutMutation( + bool identifierExists) + { + var f = CreateFixture(); + var actor = UserKey.New(); + var target = UserKey.New(); + var other = UserKey.New(); + const string identifier = "other@example.com"; + + var context = TestAccessContext.ForTargetUser( + actor, + target, + UAuthActions.Credentials.CompleteResetAdmin); + + f.IdentifierResolver + .Setup(x => x.ResolveAsync( + context.ResourceTenant, + identifier, + It.IsAny())) + .ReturnsAsync(identifierExists + ? CreateResolution(context, identifier, other) + : null); + + var act = () => f.Sut.CompleteResetAsync( + context, + new CompleteResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetToken = "valid-token", + NewSecret = "new-password" + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should() + .Be("credential_target_mismatch"); + + f.SecurityManager.Verify( + x => x.GetOrCreateFactorAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.CredentialStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task BeginResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireTarget() + { + var f = CreateFixture(); + const string identifier = "unknown@example.com"; + + var context = TestAccessContext.WithAction( + UAuthActions.Credentials.BeginResetAnonymous); + + f.IdentifierResolver + .Setup(x => x.ResolveAsync( + context.ResourceTenant, + identifier, + It.IsAny())) + .ReturnsAsync((LoginIdentifierResolution?)null); + + var result = await f.Sut.BeginResetAsync( + context, + new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.ExpiresAt.Should().BeAfter(Now); + + f.SecurityManager.Verify( + x => x.GetOrCreateFactorAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.ResetNotifier.Verify( + x => x.NotifyAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CompleteResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireTarget() + { + var f = CreateFixture(); + const string identifier = "unknown@example.com"; + + var context = TestAccessContext.WithAction( + UAuthActions.Credentials.CompleteResetAnonymous); + + f.IdentifierResolver + .Setup(x => x.ResolveAsync( + context.ResourceTenant, + identifier, + It.IsAny())) + .ReturnsAsync((LoginIdentifierResolution?)null); + + var result = await f.Sut.CompleteResetAsync( + context, + new CompleteResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetToken = "some-token", + NewSecret = "new-password" + }); + + result.Succeeded.Should().BeTrue(); + + f.SecurityManager.Verify( + x => x.GetOrCreateFactorAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private static LoginIdentifierResolution CreateResolution( + AccessContext context, + string identifier, + UserKey userKey) + { + return new LoginIdentifierResolution + { + Tenant = context.ResourceTenant, + UserKey = userKey, + RawIdentifier = identifier, + NormalizedIdentifier = identifier, + BuiltInType = UserIdentifierType.Email, + IsVerified = true + }; + } + + // --------------------------------------------------------- // Helpers // --------------------------------------------------------- @@ -813,6 +1031,17 @@ public Fixture() CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + CredentialStoreFactory .Setup(x => x.Create(It.IsAny())) .Returns(CredentialStore.Object); From 652278a4b7e59403573769436d22e96ebbdca814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 00:01:20 +0300 Subject: [PATCH 02/26] fix(credentials): prevent user enumeration in reset flows --- .../Services/CredentialManagementService.cs | 53 +++++--- .../CredentialManagementTests.cs | 50 +++++++- .../Credentials/ResetPasswordTests.cs | 116 ++++++++++++++---- 3 files changed, 178 insertions(+), 41 deletions(-) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index db3982ec..b8d41df0 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -296,9 +296,6 @@ public async Task CompleteResetAsync(AccessContext conte var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); - if (!state.HasActiveReset(now)) - throw new UAuthConflictException("reset_request_not_active"); - if (state.IsResetExpired(now)) { var version2 = state.SecurityVersion; @@ -307,11 +304,23 @@ public async Task CompleteResetAsync(AccessContext conte throw new UAuthConflictException("reset_expired"); } + if (!state.HasActiveReset(now)) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + + throw new UAuthConflictException("reset_request_not_active"); + } + if (!_tokenHasher.Verify(state.ResetTokenHash!, request.ResetToken)) { var version = state.SecurityVersion; var failed = state.RegisterResetFailure(now, _options.ResetCredential.MaxAttempts); await _authenticationSecurityManager.UpdateAsync(failed, version, innerCt); + + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + throw new UAuthConflictException("invalid_reset_token"); } @@ -320,7 +329,12 @@ public async Task CompleteResetAsync(AccessContext conte var pwd = credentials.OfType().FirstOrDefault(c => c.Security.IsUsable(now)); if (pwd is null) + { + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + throw new UAuthNotFoundException("credential_not_found"); + } if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) throw new UAuthValidationException("credential_secret_same"); @@ -341,28 +355,29 @@ public async Task CompleteResetAsync(AccessContext conte return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task CancelResetAsync(AccessContext context, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); + // TODO: Implement cancel reset functionality if needed. For now, this method is commented out as it may not be required in the current implementation. + //public async Task CancelResetAsync(AccessContext context, CancellationToken ct = default) + //{ + // ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => - { - var userKey = context.GetTargetUserKey(); + // var cmd = new AccessCommand(async innerCt => + // { + // var userKey = context.GetTargetUserKey(); - var state = await _authenticationSecurityManager - .GetOrCreateFactorAsync(context.ResourceTenant, userKey, CredentialType.Password, innerCt); + // var state = await _authenticationSecurityManager + // .GetOrCreateFactorAsync(context.ResourceTenant, userKey, CredentialType.Password, innerCt); - if (!state.HasActiveReset(_clock.UtcNow)) - return CredentialActionResult.Success(); + // if (!state.HasActiveReset(_clock.UtcNow)) + // return CredentialActionResult.Success(); - var updated = state.ClearReset(); - await _authenticationSecurityManager.UpdateAsync(updated, state.SecurityVersion, innerCt); + // var updated = state.ClearReset(); + // await _authenticationSecurityManager.UpdateAsync(updated, state.SecurityVersion, innerCt); - return CredentialActionResult.Success(); - }); + // return CredentialActionResult.Success(); + // }); - return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); - } + // return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + //} public async Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs index e5846a5d..89403219 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -302,7 +302,8 @@ public async Task AnonymousCompleteReset_ReusedToken_ShouldReject() token, replayPassword); - replay.IsSuccessStatusCode.Should().BeFalse(); + // Should be true for enumeration. + replay.IsSuccessStatusCode.Should().BeTrue(); await AssertPasswordAsync(factory, user, newPassword); await AssertPasswordRejectedAsync(factory, user, replayPassword); @@ -353,6 +354,53 @@ public async Task AdminBeginReset_Unauthenticated_ShouldReject() } + [Fact] + public async Task AnonymousCompleteReset_InvalidProof_ShouldNotRevealIdentifierExistence() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var existingUser = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var unknownIdentifier = $"unknown-{Guid.NewGuid():N}"; + const string invalidToken = "invalid-reset-proof"; + + var existingResponse = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + existingUser.Identifier, + invalidToken, + "New-Password-123!"); + + var unknownResponse = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + unknownIdentifier, + invalidToken, + "New-Password-123!"); + + // Same observable HTTP status. + existingResponse.StatusCode.Should() + .Be(unknownResponse.StatusCode); + + // Same observable response body. + var existingBody = await existingResponse.Content.ReadAsStringAsync(); + var unknownBody = await unknownResponse.Content.ReadAsStringAsync(); + + existingBody.Should().Be(unknownBody); + + // Existing user's credential must remain unchanged. + await AssertPasswordAsync( + factory, + existingUser, + existingUser.Secret); + } + + private static HttpClient CreateClient(AuthServerFactory factory) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 99785fda..de39decc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -1,9 +1,13 @@ -using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using System.Text.Json; @@ -126,9 +130,12 @@ await service.CompleteResetAsync( public async Task Reset_token_should_lock_after_max_attempts() { const int maxAttempts = 3; + var (runtime, notifier) = CreateRuntime(maxAttempts); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Code); @@ -136,22 +143,24 @@ public async Task Reset_token_should_lock_after_max_attempts() for (var i = 0; i < maxAttempts; i++) { - Func invalidAttempt = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(wrongToken, "newpass123")); - - // Do not swallow unrelated failures. - await invalidAttempt.Should() - .ThrowAsync(); + var result = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(wrongToken, "newpass123")); + + result.Succeeded.Should().BeTrue(); + + await AssertPasswordAsync(runtime, userKey, "admin"); } - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "newpass123")); + // Even the correct token must no longer work. + var afterLockout = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "newpass123")); - await act.Should().ThrowAsync(); + afterLockout.Succeeded.Should().BeTrue(); + + await AssertPasswordAsync(runtime, userKey, "admin"); + await AssertPasswordRejectedAsync(runtime, userKey, "newpass123"); } [Fact] @@ -160,6 +169,8 @@ public async Task Reset_token_should_be_single_use() var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); @@ -169,12 +180,17 @@ public async Task Reset_token_should_be_single_use() first.Succeeded.Should().BeTrue(); - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "anotherpass")); + await AssertPasswordAsync(runtime, userKey, "newpass123"); - await act.Should().ThrowAsync(); + var replay = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "anotherpass")); + + replay.Succeeded.Should().BeTrue(); + + await AssertPasswordAsync(runtime, userKey, "newpass123"); + await AssertPasswordRejectedAsync(runtime, userKey, "anotherpass"); + await AssertPasswordRejectedAsync(runtime, userKey, "admin"); } [Fact] @@ -259,8 +275,7 @@ private static CompleteResetCredentialRequest CompleteRequest( NewSecret = newSecret }; - private sealed class RecordingCredentialResetNotifier - : ICredentialResetNotifier + private sealed class RecordingCredentialResetNotifier : ICredentialResetNotifier { public List Notifications { get; } = []; @@ -273,4 +288,63 @@ public Task NotifyAsync( return Task.CompletedTask; } } + + private static async Task AssertPasswordAsync(TestAuthRuntime runtime, UserKey userKey, string expectedPassword) + { + using var scope = runtime.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var hasher = scope.ServiceProvider + .GetRequiredService(); + + var store = storeFactory.Create(TenantKeys.Single); + + var credentials = await store.GetByUserAsync(userKey); + + var credential = credentials.Single(); + + hasher.Verify( + credential.SecretHash, + expectedPassword).Should().BeTrue(); + } + + private static async Task AssertPasswordRejectedAsync(TestAuthRuntime runtime, UserKey userKey, string rejectedPassword) + { + using var scope = runtime.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var hasher = scope.ServiceProvider + .GetRequiredService(); + + var store = storeFactory.Create(TenantKeys.Single); + + var credentials = await store.GetByUserAsync(userKey); + + var credential = credentials.Single(); + + hasher.Verify( + credential.SecretHash, + rejectedPassword).Should().BeFalse(); + } + + + private static async Task GetAdminUserKeyAsync(TestAuthRuntime runtime) + { + using var scope = runtime.Services.CreateScope(); + + var resolver = scope.ServiceProvider + .GetRequiredService(); + + var resolution = await resolver.ResolveAsync( + TenantKeys.Single, + "admin", + CancellationToken.None); + + resolution.Should().NotBeNull(); + return resolution!.UserKey!.Value; + } } From df4cbe690f15562205023935e0b31ad6a86a51d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 11:50:57 +0300 Subject: [PATCH 03/26] refactor(contracts): remove unused request properties --- .../Contracts/Logout/LogoutAllRequest.cs | 6 ------ .../Request/AddCredentialRequest.cs | 10 +++++----- .../Request/BeginResetCredentialRequest.cs | 2 -- .../Request/ChangeCredentialRequest.cs | 1 - .../Request/RevokeCredentialRequest.cs | 15 +++++---------- .../Credentials/CredentialEndpointHandlerTests.cs | 8 +------- .../CredentialManagementServiceTests.cs | 1 - 7 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs index 052ef9f6..fc3cb8b2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -4,12 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record LogoutAllRequest { - /// - /// The current session initiating the logout-all operation. - /// Used to resolve the active chain when ExceptCurrent is true. - /// - public AuthSessionId? CurrentSessionId { get; init; } - /// /// If true, the current session will NOT be revoked. /// diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs index fcb84bed..8845b42f 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -1,10 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; - -namespace CodeBeam.UltimateAuth.Credentials.Contracts; +namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialRequest() { - public CredentialType Type { get; init; } public required string Secret { get; init; } - public string? Source { get; init; } + + // TODO: Add them after passkey etc. are implemented + //public CredentialType Type { get; init; } + //public string? Source { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs index 437ce3e5..d37305dd 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs @@ -7,6 +7,4 @@ public sealed record BeginResetCredentialRequest public required string Identifier { get; init; } public CredentialType CredentialType { get; init; } public ResetCodeType ResetCodeType { get; init; } - public string? Channel { get; init; } - public TimeSpan? Validity { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs index 56928ebc..ee888752 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,7 +2,6 @@ public sealed record ChangeCredentialRequest { - public Guid Id { get; init; } public string? CurrentSecret { get; init; } public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs index b6a64cd6..3d0f77e6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -4,14 +4,9 @@ public sealed record RevokeCredentialRequest { public Guid Id { get; init; } - /// - /// If specified, credential is revoked until this time. - /// Null means permanent revocation. - /// - public DateTimeOffset? Until { get; init; } - - /// - /// Optional human-readable reason for audit/logging purposes. - /// - public string? Reason { get; init; } + // TODO: Add them after passkey etc. are implemented + // + // Optional human-readable reason for audit/logging purposes. + // + //public string? Reason { get; init; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index a30dcddb..6dac7de3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -72,7 +72,6 @@ public async Task AddAsync_WhenUnauthenticated_ReturnsUnauthorized() f.SetJsonBody(new AddCredentialRequest { - Type = CredentialType.Password, Secret = "new-password" }); @@ -92,9 +91,7 @@ public async Task AddAsync_WhenAuthenticated_ForwardsRequestUsingAddSelf() f.SetJsonBody(new AddCredentialRequest { - Type = CredentialType.Password, Secret = "new-password", - Source = "test" }); f.SetupAccess( @@ -110,9 +107,7 @@ public async Task AddAsync_WhenAuthenticated_ForwardsRequestUsingAddSelf() .Setup(x => x.AddAsync( access, It.Is(r => - r.Type == CredentialType.Password && - r.Secret == "new-password" && - r.Source == "test"), + r.Secret == "new-password"), f.HttpContext.RequestAborted)) .ReturnsAsync(serviceResult); @@ -382,7 +377,6 @@ public async Task AddAdminAsync_WhenAuthenticated_UsesAddAdminForTargetUser() f.SetJsonBody(new AddCredentialRequest { - Type = CredentialType.Password, Secret = "admin-set-password" }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 70b485d3..9eeb198b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -91,7 +91,6 @@ public async Task AddAsync_HashesSecretAndPersistsCredentialForTargetUser() context, new AddCredentialRequest { - Type = CredentialType.Password, Secret = "new-password" }); From a8a3b179c52f53281052a82a72c2c60c963c9818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 12:11:24 +0300 Subject: [PATCH 04/26] fix(auth): enforce supported credential types in authentication flows --- .../Contracts/Login/LoginRequest.cs | 2 +- .../Endpoints/LoginEndpointHandler.cs | 8 +-- .../Flows/Login/LoginOrchestrator.cs | 11 ++-- .../Server/LoginEndpointHandlerTests.cs | 4 +- .../Server/LoginOrchestratorTests.cs | 56 +++++++++++++++++++ 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index ae45a071..5ee258d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -6,7 +6,7 @@ public sealed record LoginRequest { public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; - public CredentialType Factor { get; init; } = CredentialType.Password; + public CredentialType CredentialType { get; init; } = CredentialType.Password; public IReadOnlyDictionary? Metadata { get; init; } /// diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 5d6b0885..547155b4 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -92,7 +92,7 @@ public async Task LoginAsync(HttpContext ctx) { Identifier = request.Identifier, Secret = request.Secret, - Factor = CredentialType.Password, + CredentialType = CredentialType.Password, PreviewReceipt = request.PreviewReceipt, RequestTokens = authFlow.AllowsTokenIssuance, Metadata = request.Metadata, @@ -161,7 +161,7 @@ public async Task TryLoginAsync(HttpContext ctx) { Identifier = request.Identifier, Secret = request.Secret, - Factor = CredentialType.Password, + CredentialType = CredentialType.Password, PreviewReceipt = request.PreviewReceipt, RequestTokens = authFlow.AllowsTokenIssuance, Metadata = request.Metadata @@ -185,7 +185,7 @@ public async Task TryLoginAsync(HttpContext ctx) var fingerprint = LoginPreviewFingerprint.Create( authFlow.Tenant, request.Identifier, - request.Factor, + request.CredentialType, request.Secret, deviceId); @@ -199,7 +199,7 @@ public async Task TryLoginAsync(HttpContext ctx) var artifact = new LoginPreviewArtifact( authFlow.Tenant, userKey, - request.Factor, + request.CredentialType, deviceId.Value, request.Identifier, authFlow.ClientProfile, diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 5c215621..dd3a4198 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -71,6 +71,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (flow.Device.DeviceId is not DeviceId deviceId) throw new UAuthConflictException("Device id could not resolved."); + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("unsupported_credential_type"); + var now = _clock.UtcNow; var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct); var userKey = resolution?.UserKey; @@ -94,7 +97,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } - factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct); + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.CredentialType, ct); if (factorState.IsLocked(now)) { @@ -107,7 +110,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req foreach (var credential in credentials) { - if (credential.IsDeleted || !credential.Security.IsUsable(now)) + if (credential.Type != request.CredentialType || credential.IsDeleted || !credential.Security.IsUsable(now)) continue; if (await provider.ValidateAsync(credential, request.Secret, ct)) @@ -168,7 +171,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req factorState = await _authenticationSecurityManager.MutateFactorAsync( flow.Tenant, userKey.Value, - request.Factor, + request.CredentialType, state => state.RegisterFailure( now, _options.Login.MaxFailedAttempts, @@ -228,7 +231,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req factorState = await _authenticationSecurityManager.MutateFactorAsync( flow.Tenant, userKey.Value, - request.Factor, + request.CredentialType, state => state.RegisterSuccess(), ct); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs index 9aa2dda2..f153abfa 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs @@ -129,7 +129,7 @@ public async Task TryLoginAsync_ExecutesPreviewWithExpectedRequestAndOptions() capturedRequest!.Identifier.Should().Be("alice"); capturedRequest.Secret.Should().Be("password"); - capturedRequest.Factor.Should() + capturedRequest.CredentialType.Should() .Be(CredentialType.Password); capturedRequest.RequestTokens.Should() @@ -592,7 +592,7 @@ private static void SetLoginJson( { Identifier = identifier, Secret = secret, - Factor = CredentialType.Password, + CredentialType = CredentialType.Password, PreviewReceipt = previewReceipt }); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index c14609dc..9dfa9ddf 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; @@ -648,4 +649,59 @@ await store.GetActiveRootByUserAsync( chain!.RootId.Should() .Be(activeRoot!.RootId); } + + [Fact] + public async Task Unsupported_credential_type_should_be_rejected() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var unsupported = (CredentialType)int.MaxValue; + + var request = new LoginRequest + { + Identifier = "user", + Secret = "user", + CredentialType = unsupported + }; + + Func act = async () => + await orchestrator.LoginAsync(flow, request); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task Unsupported_credential_type_should_not_affect_password_lockout() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var request = new LoginRequest + { + Identifier = "user", + Secret = "wrong", + CredentialType = (CredentialType)int.MaxValue + }; + + Func act = async () => + await orchestrator.LoginAsync(flow, request); + + await act.Should().ThrowAsync(); + + var factory = runtime.Services + .GetRequiredService(); + + var store = factory.Create(TenantKeys.Single); + + var state = await store.GetAsync( + TestUsers.User, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + (state?.FailedAttempts ?? 0).Should().Be(0); + } } From 8bf99d45da50f81c5c4558ea547f1383c3ae4b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 12:56:58 +0300 Subject: [PATCH 05/26] fix(contracts): align required fields and runtime validation --- .../Requests/AuthorizationCheckRequest.cs | 2 +- .../Endpoints/AuthorizationEndpointHandler.cs | 9 ++++----- .../Request/ChangeCredentialRequest.cs | 4 ++++ .../Request/CompleteResetCredentialRequest.cs | 6 +++--- .../Services/CredentialManagementService.cs | 5 ++++- .../Client/UAuthClientAuthorizationTests.cs | 2 +- .../Client/UAuthClientCredentialsTests.cs | 6 +++--- .../Credentials/CredentialManagementServiceTests.cs | 3 +-- 8 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs index 1046ce61..a1403eb3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -3,6 +3,6 @@ public sealed record AuthorizationCheckRequest { public required string Action { get; init; } - public string? Resource { get; init; } + public required string Resource { get; init; } public string? ResourceId { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 08ba606f..5664471d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -39,11 +39,10 @@ public async Task CheckAsync(HttpContext ctx) var req = await ctx.ReadJsonAsync(ctx.RequestAborted); - if (string.IsNullOrWhiteSpace(req.Resource)) - return Results.BadRequest("Resource is required for authorization check."); - - if (string.IsNullOrWhiteSpace(req.Action)) - return Results.BadRequest("Action is required for authorization check."); + if (req is null || string.IsNullOrWhiteSpace(req.Action) || string.IsNullOrWhiteSpace(req.Resource)) + { + return Results.BadRequest("Action and Resource are required."); + } var accessContext = await _accessContextFactory.CreateAsync( flow, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs index ee888752..5e4d36aa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,6 +2,10 @@ public sealed record ChangeCredentialRequest { + /// + /// Required for self-service credential changes. + /// Not required for authorized administrative changes. + /// public string? CurrentSecret { get; init; } public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs index bd49cb6d..8c782a43 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs @@ -4,8 +4,8 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CompleteResetCredentialRequest { - public string? Identifier { get; init; } - public CredentialType CredentialType { get; init; } - public string? ResetToken { get; init; } + public required string Identifier { get; init; } + public CredentialType CredentialType { get; init; } = CredentialType.Password; + public required string ResetToken { get; init; } public required string NewSecret { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index b8d41df0..1a08a788 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -135,10 +135,13 @@ public async Task ChangeSecretAsync(AccessContext contex if (pwd.UserKey != subjectUser) throw new UAuthNotFoundException("credential_not_found"); + if (string.IsNullOrWhiteSpace(request.NewSecret)) + throw new UAuthValidationException("new_secret_required"); + if (context.IsSelfAction) { if (string.IsNullOrWhiteSpace(request.CurrentSecret)) - throw new UAuthNotFoundException("current_secret_required"); + throw new UAuthValidationException("current_secret_required"); if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) throw new UAuthConflictException("invalid_credentials"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs index 664248a7..073d299a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs @@ -80,7 +80,7 @@ public async Task Check_Should_Return_Result() })); var client = CreateClient(); - var result = await client.Authorization.CheckAsync(new AuthorizationCheckRequest() { Action = UAuthActions.Authorization.Roles.CreateAdmin }); + var result = await client.Authorization.CheckAsync(new AuthorizationCheckRequest() { Action = UAuthActions.Authorization.Roles.CreateAdmin, Resource = "roles" }); result.IsSuccess.Should().BeTrue(); result.Value!.IsAllowed.Should().BeTrue(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs index 758109cb..9f9ab6d9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs @@ -99,7 +99,7 @@ public async Task CompleteResetMy_Should_Publish_Event_On_Success() .ReturnsAsync(SuccessJson(new CredentialActionResult())); var client = CreateCredentialClient(); - await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth", Identifier = "user1", ResetToken = "fake_token" }); Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.CredentialsChanged)), Times.Once); } @@ -110,7 +110,7 @@ public async Task CompleteResetMy_Should_NOT_Publish_Event_On_Failure() .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); var client = CreateCredentialClient(); - await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth", Identifier = "user1", ResetToken = "fake_token" }); Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); } @@ -136,7 +136,7 @@ public async Task CompleteResetUser_Should_NOT_Publish_Event() var client = CreateCredentialClient(); var userKey = UserKey.FromString("user-1"); - await client.Credentials.CompleteResetUserAsync(userKey, new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + await client.Credentials.CompleteResetUserAsync(userKey, new CompleteResetCredentialRequest() { NewSecret = "uauth", Identifier = "user1", ResetToken = "fake_token" }); Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 9eeb198b..35ec100f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -165,8 +165,7 @@ public async Task ChangeSecretAsync_Self_WhenCurrentSecretMissing_ThrowsNotFound }); await act.Should() - .ThrowAsync() - .WithMessage("*current_secret_required*"); + .ThrowAsync(); f.Hasher.Verify( x => x.Hash(It.IsAny()), From 68c405d56a0b6a41bc12328341b2ec0dce0e55d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 13:25:11 +0300 Subject: [PATCH 06/26] fix(users): populate missing public contract fields --- .../Dtos/CredentialInfo.cs | 2 +- .../Services/CredentialManagementService.cs | 1 + .../Mappers/UserStatusMapper.cs | 11 +++++---- .../Domain/UserProfile.cs | 24 +++++++++++++++---- .../Services/UserApplicationService.cs | 12 ++++++++-- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs index 6141c3d0..f1a93190 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record CredentialInfo { - public Guid Id { get; set; } + public Guid Id { get; init; } public CredentialType Type { get; init; } public CredentialSecurityStatus Status { get; init; } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 1a08a788..3a56ef57 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -73,6 +73,7 @@ public async Task GetAllAsync(AccessContext context, Cance Id = c.Id, Type = c.Type, Status = c.Security.Status(now), + CreatedAt = c.CreatedAt, ExpiresAt = c.Security.ExpiresAt, RevokedAt = c.Security.RevokedAt, LastUsedAt = c.Metadata.LastUsedAt, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs index 2effff52..81325a06 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using System.Net.NetworkInformation; namespace CodeBeam.UltimateAuth.Users.Contracts; @@ -13,7 +14,7 @@ public static UserStatus ToUserStatus(this SelfAssignableUserStatus selfStatus) case SelfAssignableUserStatus.SelfSuspended: return UserStatus.SelfSuspended; default: - throw new NotImplementedException(); + throw new ArgumentOutOfRangeException(nameof(selfStatus), selfStatus, "Unsupported user status."); } } @@ -34,11 +35,11 @@ public static UserStatus ToUserStatus(this AdminAssignableUserStatus status) case AdminAssignableUserStatus.PendingActivation: return UserStatus.PendingActivation; case AdminAssignableUserStatus.PendingVerification: - return UserStatus.PendingActivation; + return UserStatus.PendingVerification; case AdminAssignableUserStatus.Unknown: return UserStatus.Unknown; default: - throw new NotImplementedException(); + throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported user status."); } } @@ -59,11 +60,11 @@ public static AdminAssignableUserStatus ToAdminAssignableUserStatus(this UserSta case UserStatus.PendingActivation: return AdminAssignableUserStatus.PendingActivation; case UserStatus.PendingVerification: - return AdminAssignableUserStatus.PendingActivation; + return AdminAssignableUserStatus.PendingVerification; case UserStatus.Unknown: return AdminAssignableUserStatus.Unknown; default: - throw new NotImplementedException(); + throw new ArgumentOutOfRangeException(nameof(status), status, "Unsupported user status."); } } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs index 127d31e7..d6f03c81 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -79,7 +79,8 @@ public static UserProfile Create( string? bio = null, string? language = null, string? timezone = null, - string? culture = null) + string? culture = null, + IReadOnlyDictionary? metadata = null) { return new UserProfile { @@ -96,10 +97,12 @@ public static UserProfile Create( Language = language, TimeZone = timezone, Culture = culture, + Metadata = metadata is null + ? null + : new Dictionary(metadata), CreatedAt = createdAt, UpdatedAt = null, DeletedAt = null, - Metadata = null, Version = 0 }; } @@ -151,10 +154,23 @@ public UserProfile UpdateLocalization(string? language, string? timeZone, string public UserProfile UpdateMetadata(IReadOnlyDictionary? metadata, DateTimeOffset now) { - if (Metadata == metadata) + if (Metadata is null && metadata is null) return this; - Metadata = metadata; + if (Metadata is not null && + metadata is not null && + Metadata.Count == metadata.Count && + Metadata.All(x => + metadata.TryGetValue(x.Key, out var value) && + value == x.Value)) + { + return this; + } + + Metadata = metadata is null + ? null + : new Dictionary(metadata); + UpdatedAt = now; return this; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 674ac3b7..4aecea0b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -105,7 +105,8 @@ await profileStore.AddAsync( bio: request.Bio, language: request.Language, timezone: request.TimeZone, - culture: request.Culture), atomicCt); + culture: request.Culture, + metadata: request.Metadata), atomicCt); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); if (!string.IsNullOrWhiteSpace(request.UserName)) @@ -371,7 +372,8 @@ public async Task CreateProfileAsync(AccessContext context, CreateProfileRequest bio: request.Bio, language: request.Language, timezone: request.TimeZone, - culture: request.Culture); + culture: request.Culture, + metadata: request.Metadata); } await store.AddAsync(profile, innerCt); @@ -940,12 +942,17 @@ public async Task> QueryUsersAsync(AccessContext contex x.Type == UserIdentifierType.Email && x.IsPrimary); + var phone = ids?.FirstOrDefault(x => + x.Type == UserIdentifierType.Phone && + x.IsPrimary); + summaries.Add(new UserSummary { UserKey = lifecycle.UserKey, DisplayName = profile?.DisplayName, UserName = username?.Value, PrimaryEmail = email?.Value, + PrimaryPhone = phone?.Value, Status = lifecycle.Status, CreatedAt = lifecycle.CreatedAt }); @@ -960,6 +967,7 @@ public async Task> QueryUsersAsync(AccessContext contex .Where(x => (x.DisplayName?.ToLowerInvariant().Contains(search) ?? false) || (x.PrimaryEmail?.ToLowerInvariant().Contains(search) ?? false) || + (x.PrimaryPhone?.ToLowerInvariant().Contains(search) ?? false) || (x.UserName?.ToLowerInvariant().Contains(search) ?? false) || x.UserKey.Value.ToLowerInvariant().Contains(search)) .ToList(); From 705806dcfdf1d7e0847be420a1c7afea642d45d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 14:28:54 +0300 Subject: [PATCH 07/26] test(authorization): cover role management and target consistency --- .../Endpoints/AuthorizationEndpointHandler.cs | 34 +- .../Services/UserRoleService.cs | 19 + .../Services/UAuthAuthorizationClient.cs | 4 +- .../AuthorizationManagementTests.cs | 914 ++++++++++++++++++ .../AuthorizationEndpointHandlerTests.cs | 31 +- .../CompiledPermissionSetTests.cs | 28 +- .../Authorization/UserRoleServiceTests.cs | 100 +- .../Client/UAuthClientAuthorizationTests.cs | 19 +- 8 files changed, 1096 insertions(+), 53 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 5664471d..f84692c5 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; @@ -118,6 +119,10 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + // These are transport rules, not business rules. The business rules are enforced in the service layer. + if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName)) + return Results.BadRequest("Invalid role assignment request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.AssignAdmin, @@ -137,6 +142,9 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName)) + return Results.BadRequest("Invalid role removal request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.RemoveAdmin, @@ -177,6 +185,9 @@ public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + if (req is null || req.Id != roleId || string.IsNullOrWhiteSpace(req.Name)) + return Results.BadRequest("Invalid role rename request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.RenameAdmin, @@ -198,6 +209,9 @@ public async Task DeleteRoleAsync(RoleId roleId, HttpContext ctx) var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + if (req is null || req.Id != roleId) + return Results.BadRequest("Invalid role deletion request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.DeleteAdmin, @@ -218,6 +232,9 @@ public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ct var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + if (req is null || req.RoleId != roleId || req.Permissions is null) + return Results.BadRequest("Invalid role permissions request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.SetPermissionsAdmin, @@ -247,6 +264,21 @@ public async Task QueryRolesAsync(HttpContext ctx) var result = await _roles.QueryAsync(accessContext, req, ctx.RequestAborted); - return Results.Ok(result); + var response = new PagedResult( + result.Items.Select(role => new RoleInfo + { + Id = role.Id, + Name = role.Name, + Permissions = role.Permissions.ToArray(), + CreatedAt = role.CreatedAt, + UpdatedAt = role.UpdatedAt + }).ToArray(), + result.TotalCount, + result.PageNumber, + result.PageSize, + result.SortBy, + result.Descending); + + return Results.Ok(response); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 9a3051d1..224b2a9b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -26,6 +26,8 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey); + var now = _clock.UtcNow; var cmd = new AccessCommand(async innerCt => @@ -48,6 +50,8 @@ public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, stri { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey); + var cmd = new AccessCommand(async innerCt => { var roleStore = _roleFactory.Create(context.ResourceTenant); @@ -68,6 +72,8 @@ public async Task> GetRolesAsync(AccessContext context { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey, allowSelf: true); + var cmd = new AccessCommand>(async innerCt => { request = request.Normalize(); @@ -132,4 +138,17 @@ public async Task> GetRolesAsync(AccessContext context return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + + private static void ValidateTargetUser(AccessContext context, UserKey targetUserKey, bool allowSelf = false) + { + if (context.TargetUserKey == targetUserKey) + return; + + if (allowSelf && + context.TargetUserKey is null && + context.ActorUserKey == targetUserKey) + return; + + throw new UAuthAuthorizationException("target_user_mismatch"); + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 822af1ae..306dbc3b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -45,7 +45,7 @@ public async Task> GetUserRolesAsync(UserKey user public async Task AssignRoleToUserAsync(AssignRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/assign"), request.RoleName); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/assign"), request); var result = UAuthResultMapper.From(raw); @@ -59,7 +59,7 @@ public async Task AssignRoleToUserAsync(AssignRoleRequest request) public async Task RemoveRoleFromUserAsync(RemoveRoleRequest request) { - var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/remove"), request.RoleName); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/remove"), request); var result = UAuthResultMapper.From(raw); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs new file mode 100644 index 00000000..4b9bfbe3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs @@ -0,0 +1,914 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Helpers; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class AuthorizationManagementTests +{ + private const string AdminBase = "/auth/admin/authorization"; + private const string SelfBase = "/auth/me/authorization"; + + // -------------------------------------------------- + // Route / Request consistency + // -------------------------------------------------- + + [Fact] + public async Task AssignRole_TargetMismatch_ShouldRejectWithoutMutation() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.AssignAdmin); + + var role = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "assign"), + new AssignRoleRequest + { + UserKey = other.UserKey, + RoleName = role.Name + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeFalse(); + + (await HasRoleAsync(factory, other.UserKey, role.Id)) + .Should().BeFalse(); + } + + [Fact] + public async Task RemoveRole_TargetMismatch_ShouldRejectWithoutMutation() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.RemoveAdmin); + + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync(factory, target.UserKey, role.Id); + await SeedAssignmentAsync(factory, other.UserKey, role.Id); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "remove"), + new RemoveRoleRequest + { + UserKey = other.UserKey, + RoleName = role.Name + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeTrue(); + + (await HasRoleAsync(factory, other.UserKey, role.Id)) + .Should().BeTrue(); + } + + [Fact] + public async Task RenameRole_TargetMismatch_ShouldRejectBothTargets() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.RenameAdmin); + + var target = await SeedRoleAsync(factory); + var other = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(target.Id, "rename"), + new RenameRoleRequest + { + Id = other.Id, + Name = "unexpected-name" + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + (await ReadRoleAsync(factory, target.Id))! + .Name.Should().Be(target.Name); + + (await ReadRoleAsync(factory, other.Id))! + .Name.Should().Be(other.Name); + } + + [Fact] + public async Task SetPermissions_TargetMismatch_ShouldNotModifyEitherRole() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.SetPermissionsAdmin); + + var target = await SeedRoleAsync(factory); + var other = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(target.Id, "permissions"), + new SetRolePermissionsRequest + { + RoleId = other.Id, + Permissions = [Permission.From("test.resource.write")] + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + (await ReadRoleAsync(factory, target.Id))! + .Permissions.Should().BeEmpty(); + + (await ReadRoleAsync(factory, other.Id))! + .Permissions.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteRole_TargetMismatch_ShouldPreserveBothRoles() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.DeleteAdmin); + + var target = await SeedRoleAsync(factory); + var other = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(target.Id, "delete"), + new DeleteRoleRequest + { + Id = other.Id + }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + (await ReadRoleAsync(factory, target.Id)) + .Should().NotBeNull(); + + (await ReadRoleAsync(factory, other.Id)) + .Should().NotBeNull(); + } + + // -------------------------------------------------- + // Role management - successful operations + // -------------------------------------------------- + + [Fact] + public async Task CreateRole_ValidRequest_ShouldPersistRole() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.CreateAdmin); + + var name = UniqueRoleName(); + var permission = Permission.From("test.resource.read"); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + $"{AdminBase}/roles/create", + new CreateRoleRequest + { + Name = name, + Permissions = [permission] + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var stored = await FindRoleByNameAsync(factory, name); + + stored.Should().NotBeNull(); + stored!.Name.Should().Be(name); + stored.Permissions.Should().Contain(permission); + } + + [Fact] + public async Task RenameRole_ValidRequest_ShouldPersistNewName() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.RenameAdmin); + + var role = await SeedRoleAsync(factory); + var newName = UniqueRoleName(); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(role.Id, "rename"), + new RenameRoleRequest + { + Id = role.Id, + Name = newName + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var stored = await ReadRoleAsync(factory, role.Id); + + stored.Should().NotBeNull(); + stored!.Name.Should().Be(newName); + stored.NormalizedName.Should() + .Be(newName.ToUpperInvariant()); + } + + [Fact] + public async Task SetPermissions_ValidRequest_ShouldReplacePermissions() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync( + factory, + admin, + UAuthActions.Authorization.Roles.SetPermissionsAdmin); + + var oldPermission = Permission.From("test.old.read"); + + var newPermission = Permission.From( + UAuthActions.Authorization.Roles.AssignAdmin); + + var role = await SeedRoleAsync(factory, [oldPermission]); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(role.Id, "permissions"), + new SetRolePermissionsRequest + { + RoleId = role.Id, + Permissions = [newPermission] + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var stored = await ReadRoleAsync(factory, role.Id); + + stored.Should().NotBeNull(); + + stored!.Permissions.Should().NotBeEmpty(); + + stored.Permissions.Should().NotContain(oldPermission); + + var normalized = PermissionNormalizer.Normalize( + [newPermission], + UAuthPermissionCatalog.GetAdminPermissions()); + + stored.Permissions.Should().BeEquivalentTo(normalized); + } + + [Fact] + public async Task AssignRole_ValidRequest_ShouldPersistAssignment() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.AssignAdmin); + + var role = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "assign"), + new AssignRoleRequest + { + UserKey = target.UserKey, + RoleName = role.Name + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeTrue(); + } + + [Fact] + public async Task RemoveRole_ValidRequest_ShouldRemoveAssignment() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.RemoveAdmin); + + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync(factory, target.UserKey, role.Id); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "remove"), + new RemoveRoleRequest + { + UserKey = target.UserKey, + RoleName = role.Name + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeFalse(); + } + + [Fact] + public async Task DeleteRole_ValidRequest_ShouldRemoveAssignments() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.DeleteAdmin); + + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync(factory, target.UserKey, role.Id); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + RoleUrl(role.Id, "delete"), + new DeleteRoleRequest + { + Id = role.Id, + Mode = default + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeFalse(); + + var stored = await ReadRoleAsync(factory, role.Id); + + // Soft delete leaves the entity marked as deleted. + // Hard delete may remove it entirely. + (stored is null || stored.IsDeleted) + .Should().BeTrue(); + } + + // -------------------------------------------------- + // Queries + // -------------------------------------------------- + + [Fact] + public async Task GetUserRoles_ShouldReturnAssignedRole() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await GrantAsync(factory, admin, + UAuthActions.Authorization.Roles.GetAdmin); + + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync(factory, target.UserKey, role.Id); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "get"), + new RoleQuery()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.UserKey.Should().Be(target.UserKey); + result.Roles.Items.Should() + .Contain(x => x.RoleId == role.Id); + } + + [Fact] + public async Task GetMyRoles_ShouldReturnOwnRole() + { + using var factory = NewFactory(); + + var user = await factory.CreateLoginUserAsync(); + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync(factory, user.UserKey, role.Id); + + using var client = await AuthenticatedClientAsync(factory, user); + + using var response = await client.PostAsJsonAsync( + $"{SelfBase}/roles/get", + new RoleQuery()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.UserKey.Should().Be(user.UserKey); + result.Roles.Items.Should() + .Contain(x => x.RoleId == role.Id); + } + + [Fact] + public async Task QueryRoles_ShouldReturnCreatedRole() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + + await GrantAsync( + factory, + admin, + UAuthActions.Authorization.Roles.QueryAdmin); + + var role = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, admin); + + using var response = await client.PostAsJsonAsync( + $"{AdminBase}/roles/query", + new RoleQuery()); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync>(); + + result.Should().NotBeNull(); + + result!.Items.Should().ContainSingle( + x => x.Id == role.Id && x.Name == role.Name); + } + + // -------------------------------------------------- + // Authentication and authorization + // -------------------------------------------------- + + [Fact] + public async Task AssignRole_Anonymous_ShouldReject() + { + using var factory = NewFactory(); + + var target = await factory.CreateLoginUserAsync(); + var role = await SeedRoleAsync(factory); + + using var client = CreateClient(factory); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "assign"), + new AssignRoleRequest + { + UserKey = target.UserKey, + RoleName = role.Name + }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeFalse(); + } + + [Fact] + public async Task AssignRole_WithoutPermission_ShouldNotMutate() + { + using var factory = NewFactory(); + + var actor = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var role = await SeedRoleAsync(factory); + + using var client = await AuthenticatedClientAsync(factory, actor); + + using var response = await client.PostAsJsonAsync( + UserRolesUrl(target.UserKey, "assign"), + new AssignRoleRequest + { + UserKey = target.UserKey, + RoleName = role.Name + }); + + response.IsSuccessStatusCode.Should().BeFalse(); + + (await HasRoleAsync(factory, target.UserKey, role.Id)) + .Should().BeFalse(); + } + + [Fact] + public async Task CreateRole_WithoutPermission_ShouldNotCreateRole() + { + using var factory = NewFactory(); + + var actor = await factory.CreateLoginUserAsync(); + var name = UniqueRoleName(); + + using var client = await AuthenticatedClientAsync(factory, actor); + + using var response = await client.PostAsJsonAsync( + $"{AdminBase}/roles/create", + new CreateRoleRequest + { + Name = name, + Permissions = [] + }); + + response.IsSuccessStatusCode.Should().BeFalse(); + + (await FindRoleByNameAsync(factory, name)) + .Should().BeNull(); + } + + [Fact] + public async Task AuthorizationCheck_WithPermission_ShouldAllow() + { + using var factory = NewFactory(); + + var user = await factory.CreateLoginUserAsync(); + + await GrantAsync( + factory, + user, + UAuthActions.Authorization.Roles.QueryAdmin); + + using var client = await AuthenticatedClientAsync(factory, user); + + using var response = await client.PostAsJsonAsync( + $"{SelfBase}/check", + new AuthorizationCheckRequest + { + Action = UAuthActions.Authorization.Roles.QueryAdmin, + Resource = "authorization.roles" + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task AuthorizationCheck_WithoutPermission_ShouldDeny() + { + using var factory = NewFactory(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = await AuthenticatedClientAsync(factory, user); + + using var response = await client.PostAsJsonAsync( + $"{SelfBase}/check", + new AuthorizationCheckRequest + { + Action = UAuthActions.Authorization.Roles.QueryAdmin, + Resource = "authorization.roles" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task AssignRole_ServiceTargetMismatch_ShouldReject() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var authorizedTarget = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await GrantAsync( + factory, + admin, + UAuthActions.Authorization.Roles.AssignAdmin); + + var role = await SeedRoleAsync(factory); + + var context = TestAccessContext.ForTargetUser( + admin.UserKey, + authorizedTarget.UserKey, + UAuthActions.Authorization.Roles.AssignAdmin, + resource: "authorization.roles"); + + using var scope = factory.Services.CreateScope(); + + var service = scope.ServiceProvider + .GetRequiredService(); + + Func act = () => service.AssignAsync( + context, + other.UserKey, + role.Name); + + await act.Should().ThrowAsync(); + + (await HasRoleAsync(factory, authorizedTarget.UserKey, role.Id)) + .Should().BeFalse(); + + (await HasRoleAsync(factory, other.UserKey, role.Id)) + .Should().BeFalse(); + } + + [Fact] + public async Task RemoveRole_ServiceTargetMismatch_ShouldReject() + { + using var factory = NewFactory(); + + var admin = await factory.CreateLoginUserAsync(); + var authorizedTarget = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + await GrantAsync( + factory, + admin, + UAuthActions.Authorization.Roles.RemoveAdmin); + + var role = await SeedRoleAsync(factory); + + await SeedAssignmentAsync( + factory, + authorizedTarget.UserKey, + role.Id); + + await SeedAssignmentAsync( + factory, + other.UserKey, + role.Id); + + var context = TestAccessContext.ForTargetUser( + admin.UserKey, + authorizedTarget.UserKey, + UAuthActions.Authorization.Roles.RemoveAdmin, + resource: "authorization.roles"); + + using var scope = factory.Services.CreateScope(); + + var service = scope.ServiceProvider + .GetRequiredService(); + + Func act = () => service.RemoveAsync( + context, + other.UserKey, + role.Name); + + await act.Should().ThrowAsync(); + + (await HasRoleAsync(factory, authorizedTarget.UserKey, role.Id)) + .Should().BeTrue(); + + (await HasRoleAsync(factory, other.UserKey, role.Id)) + .Should().BeTrue(); + } + + [Fact] + public async Task RoleStore_ShouldIsolateRolesBetweenTenants() + { + using var factory = NewFactory(); + + var tenantA = TenantKeys.Single; + var tenantB = TenantKey.FromExternal("integration-tenant-b"); + + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var role = Role.Create( + id: null, + tenant: tenantA, + name: UniqueRoleName(), + permissions: [], + now: factory.Clock.UtcNow); + + await storeFactory.Create(tenantA).AddAsync(role); + + var fromA = await storeFactory.Create(tenantA) + .GetAsync(new RoleKey(tenantA, role.Id)); + + var fromB = await storeFactory.Create(tenantB) + .GetAsync(new RoleKey(tenantB, role.Id)); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + } + + // -------------------------------------------------- + // Test infrastructure + // -------------------------------------------------- + + private static AuthServerFactory NewFactory() + { + var factory = new AuthServerFactory(); + factory.Clock.Reset(); + return factory; + } + + private static string UniqueRoleName() + => $"role-{Guid.NewGuid():N}"; + + private static string RoleUrl(RoleId id, string operation) + => $"{AdminBase}/roles/{id.Value}/{operation}"; + + private static string UserRolesUrl( + UserKey userKey, + string operation) + => $"{AdminBase}/users/{userKey.Value}/roles/{operation}"; + + private static async Task GrantAsync( + AuthServerFactory factory, + IntegrationTestUser user, + params string[] permissions) + { + await factory.GrantPermissionsAsync( + user.UserKey, + permissions); + } + + private static HttpClient CreateClient(AuthServerFactory factory) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + $"authorization-test-{Guid.NewGuid():N}"); + + return client; + } + + private static async Task AuthenticatedClientAsync( + AuthServerFactory factory, + IntegrationTestUser user) + { + var client = CreateClient(factory); + + using var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers.TryGetValues("Set-Cookie", out var values) + .Should().BeTrue(); + + var cookie = values! + .First(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)) + .Split(';', 2)[0]; + + client.DefaultRequestHeaders.Add("Cookie", cookie); + + return client; + } + + private static async Task SeedRoleAsync( + AuthServerFactory factory, + IEnumerable? permissions = null) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var role = Role.Create( + id: null, + tenant: TenantKeys.Single, + name: UniqueRoleName(), + permissions: permissions, + now: factory.Clock.UtcNow); + + await storeFactory + .Create(TenantKeys.Single) + .AddAsync(role); + + return role; + } + + private static async Task ReadRoleAsync( + AuthServerFactory factory, + RoleId roleId) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + return await storeFactory + .Create(TenantKeys.Single) + .GetAsync(new RoleKey(TenantKeys.Single, roleId)); + } + + private static async Task FindRoleByNameAsync( + AuthServerFactory factory, + string name) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + return await storeFactory + .Create(TenantKeys.Single) + .GetByNameAsync(name.ToUpperInvariant()); + } + + private static async Task SeedAssignmentAsync( + AuthServerFactory factory, + UserKey userKey, + RoleId roleId) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + await storeFactory + .Create(TenantKeys.Single) + .AssignAsync(userKey, roleId, factory.Clock.UtcNow); + } + + private static async Task HasRoleAsync( + AuthServerFactory factory, + UserKey userKey, + RoleId roleId) + { + using var scope = factory.Services.CreateScope(); + + var storeFactory = scope.ServiceProvider + .GetRequiredService(); + + var assignments = await storeFactory + .Create(TenantKeys.Single) + .GetAssignmentsAsync(userKey); + + return assignments.Any(x => x.RoleId == roleId); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs index 655e92f7..e93d3606 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs @@ -671,7 +671,7 @@ public async Task QueryRolesAsync_WhenAuthenticated_ForwardsQuery() } [Fact] - public async Task RenameRoleAsync_WhenBodyContainsDifferentId_UsesRouteRoleId() + public async Task RenameRoleAsync_WhenBodyContainsDifferentId_ReturnsBadRequest() { var f = new Fixture(); @@ -684,35 +684,18 @@ public async Task RenameRoleAsync_WhenBodyContainsDifferentId_UsesRouteRoleId() Name = "Operators" }); - var accessContext = f.AccessContext( - UAuthActions.Authorization.Roles.RenameAdmin); - - f.SetupAccessContext( - UAuthActions.Authorization.Roles.RenameAdmin, - "authorization.roles", - routeRoleId.ToString(), - accessContext); + var result = await f.Sut.RenameRoleAsync(routeRoleId, ctx); - f.Roles - .Setup(x => x.RenameAsync( - accessContext, - routeRoleId, - "Operators", - ctx.RequestAborted)) - .Returns(Task.CompletedTask); + result.Should().BeAssignableTo(); - await f.Sut.RenameRoleAsync(routeRoleId, ctx); + var statusResult = (IStatusCodeHttpResult)result; - f.Roles.Verify(x => x.RenameAsync( - accessContext, - routeRoleId, - "Operators", - ctx.RequestAborted), - Times.Once); + statusResult.StatusCode.Should().Be( + StatusCodes.Status400BadRequest); f.Roles.Verify(x => x.RenameAsync( It.IsAny(), - bodyRoleId, + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs index 74578556..b24715ac 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs @@ -1,4 +1,7 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -110,4 +113,27 @@ public void PrefixPermission_DoesNotMatchSimilarPrefix() Assert.False(set.IsAllowed("usersettings.update.admin")); } + + [Fact] + public void SetPermissions_ShouldReplaceExistingPermissions() + { + var now = DateTimeOffset.UtcNow; + + var permissions = UAuthPermissionCatalog.GetAdminPermissions(); + + var oldPermission = Permission.From(permissions[0]); + var newPermission = Permission.From(permissions[1]); + + var role = Role.Create( + id: null, + tenant: TenantKeys.Single, + name: "test-role", + permissions: [oldPermission], + now: now); + + role.SetPermissions([newPermission], now.AddMinutes(1)); + + role.Permissions.Should().Contain(newPermission); + role.Permissions.Should().NotContain(oldPermission); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs index 54ee688b..1cfcc08f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs @@ -9,7 +9,6 @@ using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; -using Microsoft.AspNetCore.Identity; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -27,8 +26,8 @@ public sealed class UserRoleServiceTests public async Task AssignAsync_WhenRoleExists_NormalizesRoleNameAndAssignsTargetUser() { var f = new Fixture(); - var context = f.Context("roles.assign"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role = f.Role("Administrators"); @@ -68,8 +67,8 @@ await f.Sut.AssignAsync( public async Task AssignAsync_WhenRoleDoesNotExist_ThrowsNotFound() { var f = new Fixture(); - var context = f.Context("roles.assign"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.RoleStore .Setup(x => x.GetByNameAsync( @@ -100,8 +99,8 @@ public async Task AssignAsync_WhenRoleDoesNotExist_ThrowsNotFound() public async Task AssignAsync_WhenRoleIsDeleted_ThrowsNotFound() { var f = new Fixture(); - var context = f.Context("roles.assign"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role = f.Role("Admin"); role.MarkDeleted(Now.AddMinutes(-1)); @@ -135,8 +134,8 @@ public async Task AssignAsync_WhenRoleIsDeleted_ThrowsNotFound() public async Task AssignAsync_UsesResourceTenantForBothStores() { var f = new Fixture(); - var context = f.Context("roles.assign"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role = f.Role("Admin"); f.RoleStore @@ -175,8 +174,8 @@ await f.Sut.AssignAsync( public async Task RemoveAsync_WhenRoleExists_NormalizesRoleNameAndRemovesAssignment() { var f = new Fixture(); - var context = f.Context("roles.remove"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role = f.Role("Operators"); @@ -209,8 +208,8 @@ await f.Sut.RemoveAsync( public async Task RemoveAsync_WhenRoleDoesNotExist_IsIdempotent() { var f = new Fixture(); - var context = f.Context("roles.remove"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.RoleStore .Setup(x => x.GetByNameAsync( @@ -237,8 +236,8 @@ public async Task RemoveAsync_WhenRoleDoesNotExist_IsIdempotent() public async Task RemoveAsync_WhenRoleIsDeleted_StillRemovesAssignment() { var f = new Fixture(); - var context = f.Context("roles.remove"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role = f.Role("Legacy"); role.MarkDeleted(Now.AddMinutes(-5)); @@ -276,8 +275,8 @@ await f.Sut.RemoveAsync( public async Task GetRolesAsync_WhenAssignmentsExist_JoinsAssignmentsWithRoles() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var admin = f.Role("Admin"); var auditor = f.Role("Auditor"); @@ -339,8 +338,8 @@ public async Task GetRolesAsync_WhenAssignmentsExist_JoinsAssignmentsWithRoles() public async Task GetRolesAsync_WhenAssignmentReferencesMissingRole_IgnoresOrphanAssignment() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var existingRole = f.Role("Admin"); var missingRoleId = RoleId.New(); @@ -386,8 +385,8 @@ public async Task GetRolesAsync_WhenAssignmentReferencesMissingRole_IgnoresOrpha public async Task GetRolesAsync_AppliesPaginationAfterJoin() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role1 = f.Role("Role 1"); var role2 = f.Role("Role 2"); @@ -435,8 +434,8 @@ public async Task GetRolesAsync_AppliesPaginationAfterJoin() public async Task GetRolesAsync_NormalizesInvalidPagingValues() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.UserRoleStore .Setup(x => x.GetAssignmentsAsync( @@ -469,8 +468,8 @@ public async Task GetRolesAsync_NormalizesInvalidPagingValues() public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.UserRoleStore .Setup(x => x.GetAssignmentsAsync( @@ -501,8 +500,8 @@ public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize() public async Task GetRolesAsync_PreservesPagingMetadata() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.UserRoleStore .Setup(x => x.GetAssignmentsAsync( @@ -537,8 +536,8 @@ public async Task GetRolesAsync_PreservesPagingMetadata() public async Task GetRolesAsync_UsesResourceTenantForBothStores() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); f.UserRoleStore .Setup(x => x.GetAssignmentsAsync( @@ -597,8 +596,8 @@ await act.Should() public async Task GetRolesAsync_WhenSortByNameAscending_SortsByName() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var charlie = f.Role("Charlie"); var alpha = f.Role("Alpha"); @@ -633,8 +632,8 @@ public async Task GetRolesAsync_WhenSortByNameAscending_SortsByName() public async Task GetRolesAsync_WhenSortByNameDescending_SortsByNameDescending() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var bravo = f.Role("Bravo"); var charlie = f.Role("Charlie"); @@ -668,8 +667,8 @@ public async Task GetRolesAsync_WhenSortByNameDescending_SortsByNameDescending() public async Task GetRolesAsync_WhenSortByAssignedAtAscending_SortsByAssignedAt() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role1 = f.Role("Role 1"); var role2 = f.Role("Role 2"); @@ -707,8 +706,8 @@ public async Task GetRolesAsync_WhenSortByAssignedAtAscending_SortsByAssignedAt( public async Task GetRolesAsync_WhenSortByAssignedAtDescending_SortsByAssignedAtDescending() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var role1 = f.Role("Role 1"); var role2 = f.Role("Role 2"); @@ -746,8 +745,8 @@ public async Task GetRolesAsync_WhenSortByAssignedAtDescending_SortsByAssignedAt public async Task GetRolesAsync_WhenSortByIsNotSpecified_DefaultsToNameAscending() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var zebra = f.Role("Zebra"); var admin = f.Role("Admin"); @@ -777,8 +776,8 @@ public async Task GetRolesAsync_WhenSortByIsNotSpecified_DefaultsToNameAscending public async Task GetRolesAsync_SortsBeforeApplyingPagination() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var charlie = f.Role("Charlie"); var alpha = f.Role("Alpha"); @@ -833,8 +832,8 @@ public async Task GetRolesAsync_SortsBeforeApplyingPagination() public async Task GetRolesAsync_WhenNamesAreEqual_UsesRoleIdAsDeterministicTieBreaker() { var f = new Fixture(); - var context = f.Context("roles.list"); var target = UserKey.New(); + var context = f.Context("roles.assign", target); var lowerId = Role.FromProjection( RoleId.From(Guid.Parse("00000000-0000-0000-0000-000000000001")), @@ -882,6 +881,56 @@ public async Task GetRolesAsync_WhenNamesAreEqual_UsesRoleIdAsDeterministicTieBr .ContainInOrder(lowerId.Id, higherId.Id); } + [Fact] + public async Task AssignAsync_WhenTargetDoesNotMatchContext_ThrowsAuthorizationException() + { + var f = new Fixture(); + + var authorizedTarget = UserKey.New(); + var otherTarget = UserKey.New(); + + var context = f.Context("roles.assign", authorizedTarget); + + Func act = () => f.Sut.AssignAsync( + context, + otherTarget, + "Admin"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("target_user_mismatch"); + + f.AccessOrchestrator.VerifyNoOtherCalls(); + f.RoleFactory.VerifyNoOtherCalls(); + f.UserRoleFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RemoveAsync_WhenTargetDoesNotMatchContext_ThrowsAuthorizationException() + { + var f = new Fixture(); + + var authorizedTarget = UserKey.New(); + var otherTarget = UserKey.New(); + + var context = f.Context("roles.remove", authorizedTarget); + + Func act = () => f.Sut.RemoveAsync( + context, + otherTarget, + "Admin"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("target_user_mismatch"); + + f.AccessOrchestrator.VerifyNoOtherCalls(); + f.RoleFactory.VerifyNoOtherCalls(); + f.UserRoleFactory.VerifyNoOtherCalls(); + } + // ========================================================= // Fixture // ========================================================= @@ -953,6 +1002,13 @@ public Fixture() public AccessContext Context(string action) => TestAccessContext.WithAction(action); + public AccessContext Context(string action, UserKey target) + => TestAccessContext.ForTargetUser( + actorUserKey: UserKey.New(), + targetUserKey: target, + action: action, + resource: "authorization.roles"); + public Role Role(string name) => global::CodeBeam.UltimateAuth.Authorization.Role.Create( RoleId.New(), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs index 073d299a..00113329 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs @@ -16,7 +16,10 @@ public class UAuthClientAuthorizationTests : UAuthClientTestBase [Fact] public async Task AssignRole_Should_Call_Correct_Endpoint_And_Publish_Event() { - Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + Request.Setup(x => x.SendJsonAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(Success()); var client = CreateClient(); @@ -29,8 +32,18 @@ public async Task AssignRole_Should_Call_Correct_Endpoint_And_Publish_Event() await client.Authorization.AssignRoleToUserAsync(request); - Request.Verify(x => x.SendJsonAsync( $"/auth/admin/authorization/users/{request.UserKey.Value}/roles/assign", request.RoleName), Times.Once); - Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + Request.Verify(x => x.SendJsonAsync( + $"/auth/admin/authorization/users/{request.UserKey.Value}/roles/assign", + It.Is(r => + r.UserKey == request.UserKey && + r.RoleName == request.RoleName), + It.IsAny()), + Times.Once); + + Events.Verify(x => x.PublishAsync( + It.Is(e => + e.Type == UAuthStateEvent.AuthorizationChanged)), + Times.Once); } [Fact] From 5ae5bea3e938183da7e6691ef8fb77935f832188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 14:47:49 +0300 Subject: [PATCH 08/26] Transport PreviewReceipt on Login an TryLogin Processes --- .../TScripts/uauth.js | 11 ++++++++++- .../wwwroot/uauth.min.js | 2 +- .../Extensions/LoginRequestFormExtensions.cs | 9 ++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index a1eddceb..226d9b32 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -75,7 +75,8 @@ window.uauth.tryAndCommit = async function (options) { remainingAttempts: result.remainingAttempts ?? null, lockoutUntilUtc: result.lockoutUntilUtc ?? null, requiresMfa: result.requiresMfa ?? false, - retryWithNewPkce: result.retryWithNewPkce ?? false + retryWithNewPkce: result.retryWithNewPkce ?? false, + previewReceipt: result.previewReceipt ?? null }; if (normalized.success) { @@ -91,6 +92,14 @@ window.uauth.tryAndCommit = async function (options) { form.appendChild(input); } + if (normalized.previewReceipt) { + const receipt = document.createElement("input"); + receipt.type = "hidden"; + receipt.name = "PreviewReceipt"; + receipt.value = normalized.previewReceipt; + form.appendChild(receipt); + } + const cp = document.createElement("input"); cp.type = "hidden"; cp.name = "__uauth_client_profile"; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 61a3dfaf..a3d5dd28 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:r,clientProfile:u}=n,o=await window.uauth.postJson({url:f,payload:r,clientProfile:u});let t=o?.body;t||(t={});const i={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(i.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in r){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=r[t]??"";n.appendChild(i)}if(i.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=i.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const f=document.createElement("input");f.type="hidden";f.name="__uauth_device";f.value=window.uauth.deviceId;n.appendChild(f);document.body.appendChild(n);n.submit()}return i};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs index 8e9defcb..a32446b1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs @@ -5,9 +5,16 @@ namespace CodeBeam.UltimateAuth.Client.Extensions; internal static class LoginRequestFormExtensions { public static IDictionary ToDictionary(this LoginRequest request) - => new Dictionary + { + var payload = new Dictionary { ["Identifier"] = request.Identifier, ["Secret"] = request.Secret }; + + if (!string.IsNullOrWhiteSpace(request.PreviewReceipt)) + payload["PreviewReceipt"] = request.PreviewReceipt; + + return payload; + } } From bc2832f243ae83c25d69d261b8a459a1f9727425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 14:53:21 +0300 Subject: [PATCH 09/26] Add Verified Username Initialization Guard on User Creation --- .../Services/UserApplicationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 4aecea0b..3a8e4f04 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -121,7 +121,7 @@ await identifierStore.AddAsync( _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, now, true, - request.UserNameVerified ? now : null), atomicCt); + allowVerifiedContacts && request.UserNameVerified ? now : null), atomicCt); } if (!string.IsNullOrWhiteSpace(request.Email)) From 4f7017bbc1dbf4e2a3ac8297f9756e4eefe78860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 9 Oct 2026 20:28:13 +0300 Subject: [PATCH 10/26] fix(core): enforce result invariants and standardize IsSuccess --- .../Components/Pages/Home.razor.cs | 2 +- .../Components/Pages/Home.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Pages/Login.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Pages/Login.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Pages/Login.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Pages/Login.razor.cs | 2 +- .../Contracts/Common/IUAuthTryResult.cs | 2 +- .../Contracts/Login/LoginStatus.cs | 7 +- .../Contracts/Login/TryLoginResult.cs | 2 +- .../Contracts/Logout/LogoutResponse.cs | 2 +- .../Contracts/Pkce/PkceCompleteResult.cs | 2 +- .../Contracts/Pkce/TryPkceLoginResult.cs | 2 +- .../Contracts/Refresh/RefreshFlowResult.cs | 15 +++- .../User/UserAuthenticationResult.cs | 6 +- .../Domain/Session/RefreshOutcome.cs | 9 +-- .../Endpoints/LoginEndpointHandler.cs | 4 +- .../Endpoints/LogoutEndpointHandler.cs | 2 +- .../Endpoints/PkceEndpointHandler.cs | 10 +-- .../Endpoints/RefreshEndpointHandler.cs | 2 +- .../Flows/Pkce/PkceValidationResult.cs | 4 +- .../Services/PkceService.cs | 6 +- .../TScripts/uauth.js | 4 +- .../wwwroot/uauth.min.js | 2 +- .../Services/Abstractions/IUserClient.cs | 26 +++++-- .../Services/UAuthFlowClient.cs | 4 +- .../Services/UAuthUserClient.cs | 4 +- .../Responses/AddCredentialResult.cs | 6 +- .../Responses/CredentialActionResult.cs | 6 +- .../Responses/IdentifierChangeResult.cs | 6 +- .../Responses/IdentifierDeleteResult.cs | 6 +- .../Responses/IdentifierVerificationResult.cs | 6 +- .../Responses/UserCreateResult.cs | 9 ++- .../Responses/UserDeleteResult.cs | 10 +-- .../Responses/UserStatusChangeResult.cs | 10 +-- .../Endpoints/UserEndpointHandler.cs | 4 +- .../LoginTests.cs | 68 +++++++++---------- .../UserLifecycleTests.cs | 6 +- .../Bunit/UAuthLoginFormTests.cs | 6 +- .../Blazor/UAuthLoginFormSubmissionTests.cs | 14 ++-- .../Client/RefreshOutcomeParserTests.cs | 18 +++++ .../Client/UAuthClientUserTests.cs | 16 ++--- .../Client/UAuthFlowClientTests.cs | 22 +++--- .../CredentialEndpointHandlerTests.cs | 2 +- .../CredentialManagementServiceTests.cs | 12 ++-- .../Credentials/ResetPasswordTests.cs | 10 +-- .../Server/LoginEndpointHandlerTests.cs | 8 +-- .../Server/LogoutEndpointHandlerTests.cs | 4 +- .../Server/PkceEndpointHandlerTests.cs | 20 +++--- .../Server/PkceServiceTests.cs | 6 +- .../Server/PkceTests.cs | 4 +- .../Server/RefreshFlowServiceTests.cs | 10 +-- .../Users/UserApplicationServiceTests.cs | 4 +- 56 files changed, 240 insertions(+), 196 deletions(-) diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs index 7897aca7..11625cbc 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs @@ -96,7 +96,7 @@ private async Task HandleLoginResult(IUAuthTryResult result) { if (result is TryPkceLoginResult pkce) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index a5f93cee..c4b475d3 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -96,7 +96,7 @@ private async Task HandleLoginResult(IUAuthTryResult result) { if (result is TryPkceLoginResult pkce) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs index f89b038e..0ce2be6f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs @@ -92,11 +92,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs index 011bdec3..2c130d17 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs @@ -161,7 +161,7 @@ private void HandleTry(IUAuthTryResult result) { if (result is TryLoginResult pkce) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs index d2186593..43b08720 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs @@ -92,11 +92,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index d9f06ffa..73d6ebd3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -161,7 +161,7 @@ private void HandleTry(IUAuthTryResult result) { if (result is TryLoginResult pkce) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs index c96497e8..8fd1ba9d 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs @@ -94,11 +94,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs index 78129102..3250a476 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -101,7 +101,7 @@ private async Task ProgrammaticLogin() private async Task HandleLoginResult(IUAuthTryResult result) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs index 50a8d74a..611e7141 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs @@ -94,11 +94,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs index 2cb3f032..616df5f2 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs @@ -101,7 +101,7 @@ private async Task ProgrammaticLogin() private async Task HandleLoginResult(IUAuthTryResult result) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs index 1fee5de4..daeda5ba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public interface IUAuthTryResult { - bool Success { get; } + bool IsSuccess { get; } AuthFailureReason? Reason { get; } int? RemainingAttempts { get; } DateTimeOffset? LockoutUntilUtc { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs index 95802a57..0f992517 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -2,7 +2,8 @@ public enum LoginStatus { - Success = 0, - RequiresContinuation = 10, - Failed = 20 + None = 0, + Success = 10, + RequiresContinuation = 20, + Failed = 30 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs index f91c14ab..ce08a546 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryLoginResult : IUAuthTryResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs index 00cd98ba..c34c89d4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs @@ -2,5 +2,5 @@ public sealed record LogoutResponse { - public bool Success { get; init; } + public bool IsSuccess { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs index bc16b023..2b584d58 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record PkceCompleteResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? FailureReason { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs index aef8a634..6efd7351 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryPkceLoginResult : IUAuthTryResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs index 59b18af1..68cb5656 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -4,7 +4,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed class RefreshFlowResult { - public bool Succeeded { get; init; } + public bool IsSuccess => + Outcome is RefreshOutcome.Success + or RefreshOutcome.NoOp + or RefreshOutcome.Touched + or RefreshOutcome.Rotated; + public RefreshOutcome Outcome { get; init; } public AuthSessionId? SessionId { get; init; } @@ -15,7 +20,6 @@ public static RefreshFlowResult ReauthRequired() { return new RefreshFlowResult { - Succeeded = false, Outcome = RefreshOutcome.ReauthRequired }; } @@ -26,9 +30,14 @@ public static RefreshFlowResult Success( AccessToken? accessToken = null, RefreshTokenInfo? refreshToken = null) { + + if (outcome is not (RefreshOutcome.Success or RefreshOutcome.NoOp or RefreshOutcome.Touched or RefreshOutcome.Rotated)) + { + throw new ArgumentOutOfRangeException(nameof(outcome)); + } + return new RefreshFlowResult { - Succeeded = true, Outcome = outcome, SessionId = sessionId, AccessToken = accessToken, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs index 78bd7e1c..687acf39 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -4,7 +4,7 @@ //public sealed class UserAuthenticationResult //{ -// public bool Succeeded { get; init; } +// public bool IsSuccess { get; init; } // public TUserId? UserId { get; init; } @@ -12,12 +12,12 @@ // public bool RequiresMfa { get; init; } -// public static UserAuthenticationResult Fail() => new() { Succeeded = false }; +// public static UserAuthenticationResult Fail() => new() { IsSuccess = false }; // public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) // => new() // { -// Succeeded = true, +// IsSuccess = true, // UserId = userId, // Claims = claims, // RequiresMfa = requiresMfa diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index da64faf0..a06626c0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -2,9 +2,10 @@ public enum RefreshOutcome { - Success = 0, // minimal transport - NoOp = 10, - Touched = 20, - Rotated = 30, + None = 0, + Success = 10, // minimal transport + NoOp = 20, + Touched = 30, + Rotated = 40, ReauthRequired = 100 } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 547155b4..f4a1cb59 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -152,7 +152,7 @@ public async Task TryLoginAsync(HttpContext ctx) { return Results.Ok(new TryLoginResult { - Success = false, + IsSuccess = false, Reason = AuthFailureReason.InvalidCredentials }); } @@ -212,7 +212,7 @@ public async Task TryLoginAsync(HttpContext ctx) return Results.Ok(new TryLoginResult { - Success = result.IsSuccess, + IsSuccess = result.IsSuccess, Reason = result.FailureReason, RemainingAttempts = result.RemainingAttempts, LockoutUntilUtc = result.LockoutUntilUtc, diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index cbaaf154..06f9b4fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -54,7 +54,7 @@ public async Task LogoutAsync(HttpContext ctx) return decision.Enabled ? Results.Redirect(decision.TargetUrl!) - : Results.Ok(new LogoutResponse { Success = true }); + : Results.Ok(new LogoutResponse { IsSuccess = true }); } public async Task LogoutDeviceSelfAsync(HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 768c4013..519dfbe2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -99,7 +99,7 @@ public async Task TryCompleteAsync(HttpContext ctx) { return Results.Ok(new TryPkceLoginResult { - Success = false, + IsSuccess = false, RetryWithNewPkce = true }); } @@ -112,11 +112,11 @@ public async Task TryCompleteAsync(HttpContext ctx) var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); - if (!validation.Success) + if (!validation.IsSuccess) { return Results.Ok(new TryPkceLoginResult { - Success = false, + IsSuccess = false, RetryWithNewPkce = true }); } @@ -144,7 +144,7 @@ public async Task TryCompleteAsync(HttpContext ctx) return Results.Ok(new TryPkceLoginResult { - Success = preview.IsSuccess, + IsSuccess = preview.IsSuccess, Reason = preview.FailureReason, RemainingAttempts = preview.RemainingAttempts, LockoutUntilUtc = preview.LockoutUntilUtc, @@ -182,7 +182,7 @@ public async Task CompleteAsync(HttpContext ctx) if (result.InvalidPkce) return Results.Unauthorized(); - if (!result.Success) + if (!result.IsSuccess) return await RedirectToLoginWithErrorAsync(ctx, auth, "invalid"); var login = result.LoginResult!; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index bc59d6b0..2d4f37ee 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -51,7 +51,7 @@ public async Task RefreshAsync(HttpContext ctx) var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); - if (!result.Succeeded) + if (!result.IsSuccess) { return Results.Unauthorized(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs index a6c0dae9..9573fd35 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs @@ -4,11 +4,11 @@ public sealed class PkceValidationResult { private PkceValidationResult(bool success, PkceValidationFailureReason reason) { - Success = success; + IsSuccess = success; FailureReason = reason; } - public bool Success { get; } + public bool IsSuccess { get; } public PkceValidationFailureReason FailureReason { get; } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs index ec17b4c9..44b15f1e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -86,13 +86,13 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); - if (!validation.Success) + if (!validation.IsSuccess) { artifact.RegisterAttempt(); return new PkceCompleteResult { - Success = false, + IsSuccess = false, FailureReason = AuthFailureReason.InvalidCredentials }; } @@ -114,7 +114,7 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo return new PkceCompleteResult { - Success = result.IsSuccess, + IsSuccess = result.IsSuccess, FailureReason = result.FailureReason, LoginResult = result }; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 226d9b32..5f204f3a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -70,7 +70,7 @@ window.uauth.tryAndCommit = async function (options) { } const normalized = { - success: result.success ?? false, + isSuccess: result.isSuccess ?? false, reason: result.reason ?? null, remainingAttempts: result.remainingAttempts ?? null, lockoutUntilUtc: result.lockoutUntilUtc ?? null, @@ -79,7 +79,7 @@ window.uauth.tryAndCommit = async function (options) { previewReceipt: result.previewReceipt ?? null }; - if (normalized.success) { + if (normalized.isSuccess) { const form = document.createElement("form"); form.method = "POST"; form.action = commitUrl; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index a3d5dd28..09a38bf1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:r,clientProfile:u}=n,o=await window.uauth.postJson({url:f,payload:r,clientProfile:u});let t=o?.body;t||(t={});const i={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(i.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in r){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=r[t]??"";n.appendChild(i)}if(i.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=i.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const f=document.createElement("input");f.type="hidden";f.name="__uauth_device";f.value=window.uauth.deviceId;n.appendChild(f);document.body.appendChild(n);n.submit()}return i};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:r,clientProfile:u}=n,o=await window.uauth.postJson({url:f,payload:r,clientProfile:u});let t=o?.body;t||(t={});const i={isSuccess:t.isSuccess??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(i.isSuccess){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in r){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=r[t]??"";n.appendChild(i)}if(i.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=i.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const f=document.createElement("input");f.type="hidden";f.name="__uauth_device";f.value=window.uauth.deviceId;n.appendChild(f);document.body.appendChild(n);n.submit()}return i};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index ed06416b..dac08dbb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -4,6 +4,8 @@ namespace CodeBeam.UltimateAuth.Client.Services; +// TODO: Add patch semantics for profile updates. + /// /// Provides user management and profile operations for both self-service and administrative scenarios. /// @@ -95,12 +97,18 @@ public interface IUserClient Task> GetMeAsync(GetProfileRequest? request = null); /// - /// Updates the current user's profile. + /// Updates a profile of the currently authenticated user. /// /// - /// The target profile is determined by . + /// This operation replaces all editable profile fields. + /// Null values clear the corresponding existing values. + /// Omitted JSON properties are treated as null. + /// + /// The target profile is selected using + /// . + /// If no profile key is specified, the default profile is updated. /// - Task UpdateMeAsync(UpdateProfileRequest request); + Task UpdateMyProfileAsync(UpdateProfileRequest request); /// /// Creates a new profile for the current user. @@ -126,9 +134,17 @@ public interface IUserClient Task> GetUserAsync(UserKey userKey, GetProfileRequest? request = null); /// - /// Updates a profile of a specific user. + /// Updates a profile of the specified user. /// - Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request); + /// + /// Requires permission to update the target user. + /// This operation replaces all editable profile fields. + /// Null values clear existing values, including values omitted + /// from the JSON request. + /// + /// If no profile key is specified, the default profile is updated. + /// + Task UpdateUserProfileAsync(UserKey userKey, UpdateProfileRequest request); /// /// Creates a profile for a specific user. diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index f103469a..3bd89207 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -89,7 +89,7 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi case UAuthSubmitMode.DirectCommit: { await _post.NavigateAsync(commitUrl, payload); - return new TryLoginResult { Success = true }; + return new TryLoginResult { IsSuccess = true }; } case UAuthSubmitMode.TryAndCommit: @@ -258,7 +258,7 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ if (mode == UAuthSubmitMode.DirectCommit) { await CompletePkceLoginAsync(request); - return new TryPkceLoginResult { Success = true }; + return new TryPkceLoginResult { IsSuccess = true }; } if (request is null) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 14c67252..84f41dcf 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -30,7 +30,7 @@ public async Task> GetMeAsync(GetProfileRequest? request = return UAuthResultMapper.FromJson(raw); } - public async Task UpdateMeAsync(UpdateProfileRequest request) + public async Task UpdateMyProfileAsync(UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url("/me/profile/update"), request); if (raw.Ok) @@ -155,7 +155,7 @@ public async Task> GetUserAsync(UserKey userKey, GetProfil return UAuthResultMapper.FromJson(raw); } - public async Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request) + public async Task UpdateUserProfileAsync(UserKey userKey, UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/update"), request); return UAuthResultMapper.From(raw); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs index 5a226706..cb969cae 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; public sealed record AddCredentialResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? Error { get; init; } @@ -14,7 +14,7 @@ public sealed record AddCredentialResult public static AddCredentialResult Success(Guid id, CredentialType type) => new() { - Succeeded = true, + IsSuccess = true, Id = id, Type = type, Error = null @@ -23,7 +23,7 @@ public static AddCredentialResult Success(Guid id, CredentialType type) public static AddCredentialResult Fail(string error) => new() { - Succeeded = false, + IsSuccess = false, Error = error, Id = null, Type = null diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs index f45a6659..e0478c9d 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs @@ -2,20 +2,20 @@ public sealed record CredentialActionResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? Error { get; init; } public static CredentialActionResult Success() => new() { - Succeeded = true + IsSuccess = true }; public static CredentialActionResult Fail(string error) => new() { - Succeeded = false, + IsSuccess = false, Error = error }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs index da9f1da4..216008c0 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs @@ -2,10 +2,10 @@ public sealed record IdentifierChangeResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? FailureReason { get; init; } - public static IdentifierChangeResult Success() => new() { Succeeded = true }; + public static IdentifierChangeResult Success() => new() { IsSuccess = true }; - public static IdentifierChangeResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; + public static IdentifierChangeResult Failed(string reason) => new() { IsSuccess = false, FailureReason = reason }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs index 00145603..e5db8797 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs @@ -2,9 +2,9 @@ public sealed record IdentifierDeleteResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? FailureReason { get; init; } - public static IdentifierDeleteResult Success() => new() { Succeeded = true }; - public static IdentifierDeleteResult Fail(string reason) => new() { Succeeded = false, FailureReason = reason }; + public static IdentifierDeleteResult Success() => new() { IsSuccess = true }; + public static IdentifierDeleteResult Fail(string reason) => new() { IsSuccess = false, FailureReason = reason }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs index b642949d..a9729d21 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs @@ -2,10 +2,10 @@ public sealed record IdentifierVerificationResult { - public bool Succeeded { get; init; } + public bool IsSuccess { get; init; } public string? FailureReason { get; init; } - public static IdentifierVerificationResult Success() => new() { Succeeded = true }; + public static IdentifierVerificationResult Success() => new() { IsSuccess = true }; - public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; + public static IdentifierVerificationResult Failed(string reason) => new() { IsSuccess = false, FailureReason = reason }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs index 66d8ef35..b596b77f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs @@ -4,11 +4,11 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserCreateResult { - public required bool Succeeded { get; init; } + public required bool IsSuccess { get; init; } /// /// Created user's key (string form of UserKey). - /// Available only when Succeeded = true. + /// Available only when IsSuccess = true. /// public string? UserKey { get; init; } @@ -17,15 +17,14 @@ public sealed record UserCreateResult public static UserCreateResult Success(UserKey userKey) => new() { - Succeeded = true, + IsSuccess = true, UserKey = userKey.Value }; public static UserCreateResult Failed(string reason) => new() { - Succeeded = false, + IsSuccess = false, FailureReason = reason }; } - diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs index 52886f50..0cd35814 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserDeleteResult { - public required bool Succeeded { get; init; } + public required bool IsSuccess { get; init; } public required DeleteMode Mode { get; init; } @@ -13,14 +13,14 @@ public sealed record UserDeleteResult public static UserDeleteResult Success(DeleteMode mode) => new() { - Succeeded = true, + IsSuccess = true, Mode = mode }; public static UserDeleteResult NotFound() => new() { - Succeeded = false, + IsSuccess = false, Mode = DeleteMode.Soft, FailureReason = "User not found." }; @@ -28,14 +28,14 @@ public static UserDeleteResult NotFound() public static UserDeleteResult AlreadyDeleted(DeleteMode mode) => new() { - Succeeded = true, + IsSuccess = true, Mode = mode }; public static UserDeleteResult Failed(DeleteMode mode, string reason) => new() { - Succeeded = false, + IsSuccess = false, Mode = mode, FailureReason = reason }; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs index 25706af4..61735a3a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserStatusChangeResult { - public required bool Succeeded { get; init; } + public required bool IsSuccess { get; init; } public UserStatus? PreviousStatus { get; init; } @@ -15,7 +15,7 @@ public sealed record UserStatusChangeResult public static UserStatusChangeResult Success(UserStatus previous, UserStatus current) => new() { - Succeeded = true, + IsSuccess = true, PreviousStatus = previous, CurrentStatus = current }; @@ -23,7 +23,7 @@ public static UserStatusChangeResult Success(UserStatus previous, UserStatus cur public static UserStatusChangeResult NoChange(UserStatus status) => new() { - Succeeded = true, + IsSuccess = true, PreviousStatus = status, CurrentStatus = status }; @@ -31,14 +31,14 @@ public static UserStatusChangeResult NoChange(UserStatus status) public static UserStatusChangeResult NotFound() => new() { - Succeeded = false, + IsSuccess = false, FailureReason = "User not found." }; public static UserStatusChangeResult Failed(string reason) => new() { - Succeeded = false, + IsSuccess = false, FailureReason = reason }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 51865ec9..65d8da2a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -53,7 +53,7 @@ public async Task CreateAsync(HttpContext ctx) var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); - return result.Succeeded + return result.IsSuccess ? Results.Ok(result) : Results.BadRequest(result); } @@ -73,7 +73,7 @@ public async Task CreateAdminAsync(HttpContext ctx) var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); - return result.Succeeded + return result.IsSuccess ? Results.Ok(result) : Results.BadRequest(result); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index 17b92bff..0f94b67c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -304,7 +304,7 @@ public async Task TryLogin_WithValidCredentials_ShouldReturnSuccessfulPreview() result.Should().NotBeNull(); - result!.Success.Should().BeTrue(); + result!.IsSuccess.Should().BeTrue(); result.Reason.Should().BeNull(); result.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); @@ -330,7 +330,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeTrue(); + result!.IsSuccess.Should().BeTrue(); var meResponse = await client.PostAsJsonAsync( "/auth/me/profile/get", @@ -364,7 +364,7 @@ public async Task TryLogin_WithInvalidCredentials_ShouldReturnFailedPreviewWitho await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNull(); @@ -390,7 +390,7 @@ public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview() var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNull(); } @@ -417,7 +417,7 @@ public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser() await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); var loginResponse = await client.PostAsJsonAsync( @@ -460,7 +460,7 @@ public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustRece .ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); // Attempt to use the valid receipt from another device. @@ -501,7 +501,7 @@ public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthentica await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); var loginResponse = await client.PostAsJsonAsync( @@ -542,7 +542,7 @@ public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackT await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); // Present user A's receipt while authenticating as user B. @@ -591,7 +591,7 @@ public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypas await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); // Receipt belongs to user A. @@ -656,7 +656,7 @@ public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed() await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); var firstCommit = await client.PostAsJsonAsync( @@ -756,7 +756,7 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc await first.Content.ReadFromJsonAsync(); firstResult.Should().NotBeNull(); - firstResult!.Success.Should().BeFalse(); + firstResult!.IsSuccess.Should().BeFalse(); var second = await client.PostAsJsonAsync( "/auth/try-login", @@ -770,7 +770,7 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc await second.Content.ReadFromJsonAsync(); secondResult.Should().NotBeNull(); - secondResult!.Success.Should().BeFalse(); + secondResult!.IsSuccess.Should().BeFalse(); // Account should now be locked. var correctAttempt = await client.PostAsJsonAsync( @@ -785,7 +785,7 @@ public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLoc await correctAttempt.Content.ReadFromJsonAsync(); correctResult.Should().NotBeNull(); - correctResult!.Success.Should().BeFalse(); + correctResult!.IsSuccess.Should().BeFalse(); correctResult.Reason.Should().Be(AuthFailureReason.LockedOut); } @@ -809,7 +809,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() await preview.Content.ReadFromJsonAsync(); previewResult.Should().NotBeNull(); - previewResult!.Success.Should().BeTrue(); + previewResult!.IsSuccess.Should().BeTrue(); var failure = await client.PostAsJsonAsync( "/auth/try-login", @@ -823,7 +823,7 @@ public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() await failure.Content.ReadFromJsonAsync(); failureResult.Should().NotBeNull(); - failureResult!.Success.Should().BeFalse(); + failureResult!.IsSuccess.Should().BeFalse(); failureResult.Reason.Should().Be(AuthFailureReason.InvalidCredentials); } @@ -849,7 +849,7 @@ public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAcc await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); // Receipt was created for the correct secret. @@ -915,7 +915,7 @@ public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAcc await previewResponse.Content.ReadFromJsonAsync(); preview.Should().NotBeNull(); - preview!.Success.Should().BeTrue(); + preview!.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); using var otherDevice = CreateClient( @@ -1013,7 +1013,7 @@ public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() await failureAfterSuccess.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); } @@ -1110,7 +1110,7 @@ public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview() await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeTrue(); + result!.IsSuccess.Should().BeTrue(); result.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); response.Headers @@ -1172,7 +1172,7 @@ public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvali var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNull(); } @@ -1233,7 +1233,7 @@ public async Task TryLogin_WithEmptyJsonObject_ShouldReturnInvalidCredentials() var result = await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNull(); } @@ -1365,8 +1365,8 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAs unknownResult.Should().NotBeNull(); existingResult.Should().NotBeNull(); - unknownResult!.Success.Should().BeFalse(); - existingResult!.Success.Should().BeFalse(); + unknownResult!.IsSuccess.Should().BeFalse(); + existingResult!.IsSuccess.Should().BeFalse(); unknownResult.Reason .Should().Be(AuthFailureReason.InvalidCredentials); @@ -1399,7 +1399,7 @@ public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt() result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNullOrWhiteSpace(); @@ -1431,7 +1431,7 @@ public async Task TryLogin_WithWrongPassword_ShouldNotIssuePreviewReceipt() result.Should().NotBeNull(); - result!.Success.Should().BeFalse(); + result!.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); result.PreviewReceipt.Should().BeNullOrWhiteSpace(); @@ -1597,7 +1597,7 @@ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockou user.Identifier, "wrong-password-1"); - first.Success.Should().BeFalse(); + first.IsSuccess.Should().BeFalse(); first.Reason.Should().Be(AuthFailureReason.InvalidCredentials); first.RemainingAttempts.Should().Be(1); first.LockoutUntilUtc.Should().BeNull(); @@ -1609,7 +1609,7 @@ public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockou user.Identifier, "wrong-password-2"); - second.Success.Should().BeFalse(); + second.IsSuccess.Should().BeFalse(); second.Reason.Should().Be(AuthFailureReason.LockedOut); second.RemainingAttempts.Should().Be(0); second.LockoutUntilUtc.Should().NotBeNull(); @@ -1630,7 +1630,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo user.Identifier, "wrong-password-1"); - first.Success.Should().BeFalse(); + first.IsSuccess.Should().BeFalse(); first.Reason.Should().Be(AuthFailureReason.InvalidCredentials); var second = await TryLoginAsync( @@ -1638,7 +1638,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo user.Identifier, "wrong-password-2"); - second.Success.Should().BeFalse(); + second.IsSuccess.Should().BeFalse(); second.Reason.Should().Be(AuthFailureReason.LockedOut); second.LockoutUntilUtc.Should().NotBeNull(); @@ -1651,7 +1651,7 @@ public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLo user.Identifier, "still-wrong"); - duringLockout.Success.Should().BeFalse(); + duringLockout.IsSuccess.Should().BeFalse(); duringLockout.Reason.Should().Be(AuthFailureReason.LockedOut); duringLockout.LockoutUntilUtc @@ -1699,7 +1699,7 @@ public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() var results = await Task.WhenAll(task1, task2); - results.Should().OnlyContain(x => !x.Success); + results.Should().OnlyContain(x => !x.IsSuccess); using var verificationClient = CreateClient( $"concurrent-failure-verification-{Guid.NewGuid():N}"); @@ -1709,7 +1709,7 @@ public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() user.Identifier, user.Secret); - verification.Success.Should().BeFalse(); + verification.IsSuccess.Should().BeFalse(); verification.Reason.Should().Be(AuthFailureReason.LockedOut); verification.RemainingAttempts.Should().Be(0); verification.LockoutUntilUtc.Should().NotBeNull(); @@ -1806,7 +1806,7 @@ public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptTo user.Identifier, user.Secret); - preview.Success.Should().BeTrue(); + preview.IsSuccess.Should().BeTrue(); preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); var receipt = preview.PreviewReceipt!; @@ -1847,7 +1847,7 @@ public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptTo user.Identifier, "another-wrong-password"); - failureState.Success.Should().BeFalse(); + failureState.IsSuccess.Should().BeFalse(); failureState.Reason.Should().Be(AuthFailureReason.LockedOut); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index 872c3a57..f9726fb1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -48,7 +48,7 @@ public async Task CreateUser_ShouldCreateUsableUser() await response.Content.ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Succeeded.Should().BeTrue(); + result!.IsSuccess.Should().BeTrue(); result.UserKey.Should().NotBe(default); // @@ -388,7 +388,7 @@ await firstResponse.Content .ReadFromJsonAsync(); firstResult.Should().NotBeNull(); - firstResult!.Succeeded.Should().BeTrue(); + firstResult!.IsSuccess.Should().BeTrue(); var firstUserKey = GetUserKey(firstResult); @@ -569,7 +569,7 @@ await response.Content .ReadFromJsonAsync(); result.Should().NotBeNull(); - result!.Succeeded.Should().BeTrue(); + result!.IsSuccess.Should().BeTrue(); return result; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs index fdb5e96d..bff645be 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs @@ -65,7 +65,7 @@ public async Task Submit_Should_Call_TryLogin() It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new TryLoginResult { Success = true }); + .ReturnsAsync(new TryLoginResult { IsSuccess = true }); var clientMock = new Mock(); clientMock.Setup(x => x.Flows).Returns(flowMock.Object); @@ -117,7 +117,7 @@ public async Task SubmitPkce_Should_Call_TryCompletePkce() flowMock.Setup(x => x.TryCompletePkceLoginAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync(new TryPkceLoginResult { Success = true }); + .ReturnsAsync(new TryPkceLoginResult { IsSuccess = true }); var clientMock = new Mock(); clientMock.Setup(x => x.Flows).Returns(flowMock.Object); @@ -167,7 +167,7 @@ public async Task Should_Invoke_OnTryResult() It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new TryLoginResult { Success = true }); + .ReturnsAsync(new TryLoginResult { IsSuccess = true }); var clientMock = new Mock(); clientMock.Setup(x => x.Flows).Returns(flowMock.Object); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs index e4d851aa..033ef102 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs @@ -81,7 +81,7 @@ public async Task Password_TryOnly_SendsExpectedLoginRequest() var result = new TryLoginResult { - Success = true + IsSuccess = true }; _client @@ -139,7 +139,7 @@ public async Task Password_TryOnly_EmitsTryResult() { var result = new TryLoginResult { - Success = true + IsSuccess = true }; _client @@ -174,7 +174,7 @@ public async Task Password_TryAndCommit_SendsEffectiveReturnUrl() var result = new TryLoginResult { - Success = true + IsSuccess = true }; _client @@ -372,7 +372,7 @@ public async Task Pkce_TryOnly_BuildsExpectedRequest() var result = new TryPkceLoginResult { - Success = true + IsSuccess = true }; _client @@ -447,7 +447,7 @@ public async Task Pkce_TryAndCommit_UsesTryAndCommitMode() var result = new TryPkceLoginResult { - Success = true + IsSuccess = true }; _client @@ -675,7 +675,7 @@ private static TryLoginResult CreateTryLoginResult() { return new TryLoginResult { - Success = true + IsSuccess = true }; } @@ -683,7 +683,7 @@ private static TryPkceLoginResult CreateTryPkceLoginResult() { return new TryPkceLoginResult { - Success = true + IsSuccess = true }; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs index 3a0cfd00..82b86599 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs @@ -1,5 +1,7 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -29,4 +31,20 @@ public void Parse_UnknownOrInvalidValues_ReturnsNone(string? input) Assert.Equal(RefreshOutcome.Success, result); } + + [Fact] + public void TryLoginResult_Should_Serialize_IsSuccess() + { + var result = new TryLoginResult + { + IsSuccess = true + }; + + var json = JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + using var document = JsonDocument.Parse(json); + + Assert.True(document.RootElement.GetProperty("isSuccess").GetBoolean()); + Assert.False(document.RootElement.TryGetProperty("success", out _)); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs index aa718bf4..ec486bc7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs @@ -38,7 +38,7 @@ public async Task UpdateMe_Should_Publish_Event_On_Success() .ReturnsAsync(Success()); var client = CreateUserClient(); - await client.Users.UpdateMeAsync(request); + await client.Users.UpdateMyProfileAsync(request); Events.Verify(x => x.PublishAsync(It.Is>(e => e.Type == UAuthStateEvent.ProfileChanged && @@ -53,7 +53,7 @@ public async Task UpdateMe_Should_NOT_Publish_Event_On_Failure() .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); var client = CreateUserClient(); - await client.Users.UpdateMeAsync(new UpdateProfileRequest()); + await client.Users.UpdateMyProfileAsync(new UpdateProfileRequest()); Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); } @@ -87,7 +87,7 @@ public async Task Query_Should_Call_Admin_Endpoint() public async Task Create_Should_Call_Public_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new UserCreateResult() { Succeeded = true })); + .ReturnsAsync(SuccessJson(new UserCreateResult() { IsSuccess = true })); var client = CreateUserClient(); await client.Users.CreateAsync(new CreateUserRequest()); @@ -98,7 +98,7 @@ public async Task Create_Should_Call_Public_Endpoint() public async Task CreateAsAdmin_Should_Call_Admin_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new UserCreateResult() { Succeeded = true})); + .ReturnsAsync(SuccessJson(new UserCreateResult() { IsSuccess = true})); var client = CreateUserClient(); await client.Users.CreateAsAdminAsync(new CreateUserRequest()); @@ -111,7 +111,7 @@ public async Task ChangeMyStatus_Should_Publish_Event_On_Success() var request = new ChangeUserStatusSelfRequest() { NewStatus = SelfAssignableUserStatus.Active }; Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { Succeeded = true })); + .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { IsSuccess = true })); var client = CreateUserClient(); await client.Users.ChangeMyStatusAsync(request); @@ -128,7 +128,7 @@ public async Task ChangeUserStatus_Should_Call_Admin_Endpoint() var userKey = UserKey.FromString("user-1"); Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { Succeeded = true })); + .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { IsSuccess = true })); var client = CreateUserClient(); await client.Users.ChangeUserStatusAsync(userKey, new ChangeUserStatusAdminRequest() { NewStatus = AdminAssignableUserStatus.Active }); @@ -141,7 +141,7 @@ public async Task DeleteUser_Should_Call_Admin_Endpoint() var userKey = UserKey.FromString("user-1"); Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new UserDeleteResult() { Succeeded = true, Mode = DeleteMode.Soft })); + .ReturnsAsync(SuccessJson(new UserDeleteResult() { IsSuccess = true, Mode = DeleteMode.Soft })); var client = CreateUserClient(); await client.Users.DeleteUserAsync(userKey, new DeleteUserRequest()); @@ -178,7 +178,7 @@ public async Task UpdateUser_Should_Call_Admin_Endpoint() .ReturnsAsync(Success()); var client = CreateUserClient(); - await client.Users.UpdateUserAsync(userKey, new UpdateProfileRequest()); + await client.Users.UpdateUserProfileAsync(userKey, new UpdateProfileRequest()); Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/profile/update", It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index 13bc71f7..bc65d66a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -72,7 +72,7 @@ private static UAuthTransportResult TryLoginResponse(bool success, AuthFailureRe Status = 200, Body = JsonSerializer.SerializeToElement(new TryLoginResult { - Success = success, + IsSuccess = success, Reason = reason }) }; @@ -103,7 +103,7 @@ public async Task TryLogin_Should_Return_Success() var client = CreateClient(mock); var result = await client.TryLoginAsync(new LoginRequest { Identifier = "admin", Secret = "admin" }, UAuthSubmitMode.TryOnly); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -117,7 +117,7 @@ public async Task TryLogin_Should_Return_Failure() var client = CreateClient(mock); var result = await client.TryLoginAsync(new LoginRequest { Identifier = "admin", Secret = "wrong" }, UAuthSubmitMode.TryOnly); - result.Success.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); } @@ -177,7 +177,7 @@ public async Task TryLogin_DirectCommit_Should_Navigate() }; var result = await client.TryLoginAsync(request, UAuthSubmitMode.DirectCommit); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); mock.Verify(x => x.NavigateAsync("/auth/login", It.Is>(d => d["Identifier"] == "admin" && d["Secret"] == "admin"), It.IsAny()), @@ -193,12 +193,12 @@ public async Task TryLogin_TryAndCommit_Should_Call_TryAndCommit() It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new TryLoginResult { Success = true }); + .ReturnsAsync(new TryLoginResult { IsSuccess = true }); var client = CreateClient(mock); var result = await client.TryLoginAsync(new LoginRequest(), UAuthSubmitMode.TryAndCommit); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -582,7 +582,7 @@ public async Task TryCompletePkce_Should_Call_Try_Endpoint() Status = 200, Body = JsonSerializer.SerializeToElement(new TryPkceLoginResult { - Success = true + IsSuccess = true }) }); @@ -598,7 +598,7 @@ public async Task TryCompletePkce_Should_Call_Try_Endpoint() }, UAuthSubmitMode.TryOnly); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -610,7 +610,7 @@ public async Task TryCompletePkce_Should_Call_TryAndCommit() It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new TryPkceLoginResult { Success = true }); + .ReturnsAsync(new TryPkceLoginResult { IsSuccess = true }); var client = CreateFlowClient(mock); @@ -624,7 +624,7 @@ public async Task TryCompletePkce_Should_Call_TryAndCommit() }, UAuthSubmitMode.TryAndCommit); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -650,7 +650,7 @@ public async Task TryCompletePkce_DirectCommit_Should_Navigate() }, UAuthSubmitMode.DirectCommit); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); mock.Verify(x => x.NavigateAsync("/auth/pkce/complete", diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index 6dac7de3..e7c280b1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -305,7 +305,7 @@ public async Task CompleteResetAsync_WhenUnauthenticated_IsAllowed() .BeOfType>() .Subject; - ok.Value!.Succeeded.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); } // ========================================================= diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 35ec100f..c38605ea 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -94,7 +94,7 @@ public async Task AddAsync_HashesSecretAndPersistsCredentialForTargetUser() Secret = "new-password" }); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); persisted.Should().NotBeNull(); persisted!.UserKey.Should().Be(user); @@ -484,7 +484,7 @@ public async Task RevokeAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFou Id = id }); - result.Succeeded.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.Error.Should().Be("credential_not_found"); f.CredentialStore.Verify(x => x.SaveAsync( @@ -534,7 +534,7 @@ public async Task RevokeAsync_WhenValid_RevokesAndSavesExpectedVersion() Id = id }); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } // --------------------------------------------------------- @@ -590,7 +590,7 @@ public async Task DeleteAsync_WhenValid_UsesRequestedDeleteMode(DeleteMode mode) Mode = mode }); - result.Succeeded.Should().BeTrue( + result.IsSuccess.Should().BeTrue( $"delete should succeed but returned '{result.Error}'"); result.Error.Should().BeNull(); @@ -637,7 +637,7 @@ public async Task DeleteAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFou Mode = DeleteMode.Hard }); - result.Succeeded.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.Error.Should().Be("credential_not_found"); f.CredentialStore.Verify(x => x.DeleteAsync( @@ -840,7 +840,7 @@ public async Task CompleteResetAsync_Anonymous_UnknownIdentifier_DoesNotRequireT NewSecret = "new-password" }); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); f.SecurityManager.Verify( x => x.GetOrCreateFactorAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index de39decc..6b2190b9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -106,7 +106,7 @@ public async Task Reset_password_with_valid_token_should_succeed() CompleteContext(), CompleteRequest(token, "newpass123")); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -147,7 +147,7 @@ public async Task Reset_token_should_lock_after_max_attempts() CompleteContext(), CompleteRequest(wrongToken, "newpass123")); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(runtime, userKey, "admin"); } @@ -157,7 +157,7 @@ public async Task Reset_token_should_lock_after_max_attempts() CompleteContext(), CompleteRequest(token, "newpass123")); - afterLockout.Succeeded.Should().BeTrue(); + afterLockout.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(runtime, userKey, "admin"); await AssertPasswordRejectedAsync(runtime, userKey, "newpass123"); @@ -178,7 +178,7 @@ public async Task Reset_token_should_be_single_use() CompleteContext(), CompleteRequest(token, "newpass123")); - first.Succeeded.Should().BeTrue(); + first.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(runtime, userKey, "newpass123"); @@ -186,7 +186,7 @@ public async Task Reset_token_should_be_single_use() CompleteContext(), CompleteRequest(token, "anotherpass")); - replay.Succeeded.Should().BeTrue(); + replay.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(runtime, userKey, "newpass123"); await AssertPasswordRejectedAsync(runtime, userKey, "anotherpass"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs index f153abfa..bfbb51c0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs @@ -74,7 +74,7 @@ public async Task TryLoginAsync_WhenCredentialsAreMissing_ReturnsInvalidCredenti .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.Reason.Should() .Be(AuthFailureReason.InvalidCredentials); @@ -182,7 +182,7 @@ public async Task TryLoginAsync_WhenPreviewFails_ForwardsFailureInformation() ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.Reason.Should() .Be(AuthFailureReason.InvalidCredentials); @@ -270,7 +270,7 @@ public async Task TryLoginAsync_WhenPreviewSucceedsAndIdentifierResolves_StoresP .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); ok.Value.PreviewReceipt.Should() .NotBeNullOrWhiteSpace(); @@ -322,7 +322,7 @@ public async Task TryLoginAsync_WhenPreviewSucceedsButIdentifierDoesNotResolve_D .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); fixture.AuthStore.Verify( x => x.StoreAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs index b21576b2..9fda0983 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs @@ -40,7 +40,7 @@ public async Task LogoutAsync_WhenSessionIsMissing_DoesNotCallFlowLogout() .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); fixture.FlowService.Verify( x => x.LogoutAsync( @@ -125,7 +125,7 @@ public async Task LogoutAsync_WhenRedirectIsDisabled_ReturnsSuccessfulResponse() .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); } // ===================================================================== diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs index 9ec29dbe..427b8429 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs @@ -201,7 +201,7 @@ public async Task TryCompleteAsync_WhenArtifactDoesNotExist_RequestsNewPkce() .Subject; ok.Value.Should().NotBeNull(); - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.RetryWithNewPkce.Should().BeTrue(); fixture.Validator.VerifyNoOtherCalls(); @@ -229,7 +229,7 @@ public async Task TryCompleteAsync_WhenArtifactIsNotPkceArtifact_RequestsNewPkce .BeOfType>() .Subject; - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.RetryWithNewPkce.Should().BeTrue(); fixture.Validator.VerifyNoOtherCalls(); @@ -269,7 +269,7 @@ public async Task TryCompleteAsync_WhenPkceValidationFails_RequestsNewPkce() .BeOfType>() .Subject; - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.RetryWithNewPkce.Should().BeTrue(); fixture.InternalFlow.VerifyNoOtherCalls(); @@ -341,7 +341,7 @@ public async Task TryCompleteAsync_WhenPkceIsValid_ValidatesCurrentContextAndUse .BeOfType>() .Subject; - ok.Value!.Success.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); ok.Value.RetryWithNewPkce.Should().BeFalse(); // SECURITY: @@ -419,7 +419,7 @@ public async Task TryCompleteAsync_WhenPreviewFails_ForwardsFailureInformation() .BeOfType>() .Subject; - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.Reason.Should().Be(AuthFailureReason.InvalidCredentials); ok.Value.RemainingAttempts.Should().Be(2); ok.Value.LockoutUntilUtc.Should().Be(lockoutUntil); @@ -457,7 +457,7 @@ public async Task TryCompleteAsync_WhenPreviewRequiresMfa_SetsRequiresMfa() .BeOfType>() .Subject; - ok.Value!.Success.Should().BeFalse(); + ok.Value!.IsSuccess.Should().BeFalse(); ok.Value.RequiresMfa.Should().BeTrue(); ok.Value.RetryWithNewPkce.Should().BeFalse(); } @@ -539,7 +539,7 @@ public async Task CompleteAsync_WhenLoginFailsWithoutHub_RedirectsToLogin() fixture.HttpContext.RequestAborted)) .ReturnsAsync(new PkceCompleteResult { - Success = false, + IsSuccess = false, InvalidPkce = false }); @@ -577,7 +577,7 @@ public async Task CompleteAsync_WhenLoginFailsWithHub_SetsHubErrorAndPreservesHu fixture.HttpContext.RequestAborted)) .ReturnsAsync(new PkceCompleteResult { - Success = false + IsSuccess = false }); fixture.AuthStore @@ -629,7 +629,7 @@ public async Task CompleteAsync_WhenSuccessfulAndRedirectDisabled_ReturnsOk() fixture.HttpContext.RequestAborted)) .ReturnsAsync(new PkceCompleteResult { - Success = true, + IsSuccess = true, LoginResult = LoginResult.Success(sessionId) }); @@ -673,7 +673,7 @@ public async Task CompleteAsync_WhenSuccessfulAndRedirectEnabled_ReturnsRedirect fixture.HttpContext.RequestAborted)) .ReturnsAsync(new PkceCompleteResult { - Success = true, + IsSuccess = true, LoginResult = LoginResult.Success(sessionId) }); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs index 5a6c4fee..7c83cf0e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs @@ -266,7 +266,7 @@ await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), CreateCompleteRequest()); - result.Success.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.FailureReason .Should().Be( AuthFailureReason.InvalidCredentials); @@ -609,7 +609,7 @@ await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), CreateCompleteRequest()); - result.Success.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.FailureReason.Should().Be( AuthFailureReason.InvalidCredentials); @@ -644,7 +644,7 @@ await fixture.Sut.CompleteAsync( AuthFlowTestFactory.New(), CreateCompleteRequest()); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); result.FailureReason.Should().BeNull(); result.LoginResult.Should().BeSameAs(loginResult); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs index 94148588..c2b97929 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs @@ -16,7 +16,7 @@ public void Pkce_Should_Succeed_With_Valid_Data() var (artifact, verifier) = TestPkceFactory.Create(); var result = validator.Validate(artifact, verifier, artifact.Context, DateTimeOffset.UtcNow); - result.Success.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } [Fact] @@ -26,7 +26,7 @@ public void Pkce_Should_Fail_With_Invalid_Verifier() var (artifact, _) = TestPkceFactory.Create(); var result = validator.Validate(artifact, "wrong_verifier", artifact.Context, DateTimeOffset.UtcNow); - result.Success.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.FailureReason.Should().Be(PkceValidationFailureReason.InvalidVerifier); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index 7b63d957..819c6be9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -90,7 +90,7 @@ public async Task RefreshAsync_PureOpaque_WhenSessionRefreshSucceeds_ReturnsExpe CreateFlow(UAuthMode.PureOpaque), CreateRequest(SessionId)); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(expectedOutcome); result.SessionId.Should().Be(SessionId); rotation.VerifyNoOtherCalls(); @@ -159,7 +159,7 @@ public async Task RefreshAsync_PureJwt_WhenRotationSucceeds_ReturnsRotatedTokens CreateFlow(UAuthMode.PureJwt), CreateRequest(refreshToken: "refresh-token")); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); result.SessionId.Should().BeNull(); result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); @@ -292,7 +292,7 @@ public async Task RefreshAsync_Hybrid_WhenAllStepsSucceed_ReturnsRotatedResultAn CreateFlow(UAuthMode.Hybrid), CreateRequest(SessionId, "refresh-token")); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); result.SessionId.Should().Be(SessionId); result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); @@ -378,7 +378,7 @@ public async Task RefreshAsync_SemiHybrid_WhenRotationSucceeds_ReturnsRotatedWit CreateFlow(UAuthMode.SemiHybrid), CreateRequest(SessionId, "refresh-token")); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); result.Outcome.Should().Be(RefreshOutcome.Rotated); result.SessionId.Should().Be(SessionId); result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); @@ -509,7 +509,7 @@ private static AuthFlowContext CreateFlow(UAuthMode mode) private static void AssertReauth(RefreshFlowResult result) { - result.Succeeded.Should().BeFalse(); + result.IsSuccess.Should().BeFalse(); result.Outcome.Should().Be(RefreshOutcome.ReauthRequired); result.SessionId.Should().BeNull(); result.AccessToken.Should().BeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 57cd6d66..e77fd1e3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -103,7 +103,7 @@ public async Task CreateUserAsync_WhenValid_CreatesLifecycleAndDefaultProfile() context, request); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); capturedLifecycle.Should().NotBeNull(); capturedLifecycle!.Tenant.Should().Be(context.ResourceTenant); @@ -156,7 +156,7 @@ public async Task CreateUserAsync_CreatesConfiguredIdentifiersForSameUser() context, request); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); identifiers.Should().HaveCount(3); From d65dac913c3fda2d7511fcb23937d1cd9dbbf5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 00:12:22 +0300 Subject: [PATCH 11/26] fix(users): enforce pagination limits and ensure consistent user queries --- ... 20261009201922_001_InitUAuth.Designer.cs} | 10 +- ...ial.cs => 20261009201922_001_InitUAuth.cs} | 16 +- .../Migrations/UAuthDbContextModelSnapshot.cs | 6 +- ... 20261009201715_001_InitUAuth.Designer.cs} | 10 +- ...ial.cs => 20261009201715_001_InitUAuth.cs} | 16 +- .../Migrations/UAuthDbContextModelSnapshot.cs | 6 +- .../uauth.db-shm | Bin 32768 -> 32768 bytes .../uauth.db-wal | Bin 1013552 -> 881712 bytes .../Abstractions/IUAuthPaginationPolicy.cs | 7 + .../Contracts/Common/PageRequest.cs | 27 +- .../Contracts/Common/PagedResult.cs | 2 +- .../Options/UAuthOptions.cs | 5 + .../Options/UAuthPaginationOptions.cs | 19 + .../Extensions/ServiceCollectionExtensions.cs | 13 +- .../Options/UAuthServerOptions.cs | 8 +- .../UAuthServerPaginationOptionsValidator.cs | 38 ++ .../Services/SessionApplicationService.cs | 6 +- .../Stores/EfCoreRoleStore.cs | 7 +- .../Stores/EfCoreRoleStoreFactory.cs | 9 +- .../Stores/InMemoryRoleStore.cs | 7 +- .../Stores/InMemoryRoleStoreFactory.cs | 7 +- .../Services/UserRoleService.cs | 6 +- .../Abstractions/IUserSummaryQueryStore.cs | 8 + .../IUserSummaryQueryStoreFactory.cs | 8 + .../Dtos/UserQuery.cs | 4 + .../Data/UAuthUsersModelBuilder.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Stores/EFCoreUserProfileStoreFactory.cs | 9 +- .../Stores/EfCoreUserIdentifierStore.cs | 9 +- .../EfCoreUserIdentifierStoreFactory.cs | 9 +- .../Stores/EfCoreUserLifecycleStore.cs | 12 +- .../Stores/EfCoreUserLifecycleStoreFactory.cs | 9 +- .../Stores/EfCoreUserProfileStore.cs | 9 +- .../Stores/EfCoreUserSummaryQueryStore.cs | 194 +++++++ .../EfCoreUserSummaryQueryStoreFactory.cs | 23 + .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Stores/InMemoryUserIdentifierStore.cs | 10 +- .../Stores/InMemoryUserLifecycleStore.cs | 10 +- .../Stores/InMemoryUserProfileStore.cs | 10 +- .../Stores/InMemoryUserSummaryQueryStore.cs | 182 +++++++ .../InMemoryUserSummaryQueryStoreFactory.cs | 27 + .../Services/UserApplicationService.cs | 123 +---- .../Infrastructure/EfCoreTestRuntime.cs | 3 +- .../UserCreationAtomicityTests.cs | 4 +- .../UserQueryPaginationTests.cs | 241 +++++++++ .../UserLifecycleTests.cs | 122 +++++ .../Store/EfCoreRoleStoreContractTests.cs | 22 +- .../Store/InMemoryRoleStoreContractTests.cs | 17 +- .../Authorization/UserRoleServiceTests.cs | 9 +- .../Core/PaginationTests.cs | 142 +++++ .../EfCoreRoleStoreTests.cs | 34 +- .../EfCoreUserIdentifierStoreTests.cs | 21 +- .../EfCoreUserLifecycleStoreTests.cs | 27 +- .../EfCoreUserProfileStoreTests.cs | 31 +- .../Server/ServerOptionsValidatorTests.cs | 133 +++++ .../Server/SessionApplicationServiceTests.cs | 2 + .../Users/IdentifierConcurrencyTests.cs | 3 +- .../EfCoreUserIdentifierStoreContractTests.cs | 20 +- .../EfCoreUserLifecycleStoreContractTests.cs | 20 +- .../EfCoreUserProfileStoreContractTests.cs | 20 +- ...nMemoryUserIdentifierStoreContractTests.cs | 6 +- ...InMemoryUserLifecycleStoreContractTests.cs | 10 +- .../InMemoryUserProfileStoreContractTests.cs | 8 +- ...fCoreUserSummaryQueryStoreContractTests.cs | 145 +++++ .../IUserSummaryQueryStoreTestDatabase.cs | 32 ++ ...emoryUserSummaryQueryStoreContractTests.cs | 156 ++++++ .../UserSummaryQueryStoreContractTests.cs | 501 ++++++++++++++++++ .../Users/UserApplicationServiceTests.cs | 230 ++++++++ 68 files changed, 2557 insertions(+), 291 deletions(-) rename samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/{20261004164613_001_Initial.Designer.cs => 20261009201922_001_InitUAuth.Designer.cs} (98%) rename samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/{20261004164613_001_Initial.cs => 20261009201922_001_InitUAuth.cs} (97%) rename samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/{20261004164018_001_Initial.Designer.cs => 20261009201715_001_InitUAuth.Designer.cs} (98%) rename samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/{20261004164018_001_Initial.cs => 20261009201715_001_InitUAuth.cs} (97%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStoreFactory.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStoreFactory.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/PaginationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/EfCoreUserSummaryQueryStoreContractTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/IUserSummaryQueryStoreTestDatabase.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/InMemoryUserSummaryQueryStoreContractTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs similarity index 98% rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs index 3f1152e9..4e2a0ca3 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs @@ -11,8 +11,8 @@ namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations { [DbContext(typeof(UAuthDbContext))] - [Migration("20261004164613_001_Initial")] - partial class _001_Initial + [Migration("20261009201922_001_InitUAuth")] + partial class _001_InitUAuth { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -572,7 +572,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "IsPrimary"); - b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); b.ToTable("UAuth_UserIdentifiers", (string)null); }); @@ -617,6 +617,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey") .IsUnique(); + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + b.ToTable("UAuth_UserLifecycles", (string)null); }); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs similarity index 97% rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs index 77f4d9bf..8b0c9c07 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations { /// - public partial class _001_Initial : Migration + public partial class _001_InitUAuth : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -492,9 +492,19 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Tenant", "UserKey", "IsPrimary" }); migrationBuilder.CreateIndex( - name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary", + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary_DeletedAt_CreatedAt_Id", table: "UAuth_UserIdentifiers", - columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary" }); + columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_Status_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "Status", "DeletedAt", "CreatedAt", "Id" }); migrationBuilder.CreateIndex( name: "IX_UAuth_UserLifecycles_Tenant_UserKey", 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 bef97e5f..de4aec39 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -569,7 +569,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "IsPrimary"); - b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); b.ToTable("UAuth_UserIdentifiers", (string)null); }); @@ -614,6 +614,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey") .IsUnique(); + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + b.ToTable("UAuth_UserLifecycles", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs similarity index 98% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs index 7df8d5ae..0f98bcf8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs @@ -11,8 +11,8 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations { [DbContext(typeof(UAuthDbContext))] - [Migration("20261004164018_001_Initial")] - partial class _001_Initial + [Migration("20261009201715_001_InitUAuth")] + partial class _001_InitUAuth { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -572,7 +572,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "IsPrimary"); - b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); b.ToTable("UAuth_UserIdentifiers", (string)null); }); @@ -617,6 +617,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey") .IsUnique(); + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + b.ToTable("UAuth_UserLifecycles", (string)null); }); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs similarity index 97% rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs index 0d43c8e2..e55065bd 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations { /// - public partial class _001_Initial : Migration + public partial class _001_InitUAuth : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -492,9 +492,19 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Tenant", "UserKey", "IsPrimary" }); migrationBuilder.CreateIndex( - name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary", + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary_DeletedAt_CreatedAt_Id", table: "UAuth_UserIdentifiers", - columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary" }); + columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_Status_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "Status", "DeletedAt", "CreatedAt", "Id" }); migrationBuilder.CreateIndex( name: "IX_UAuth_UserLifecycles_Tenant_UserKey", 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 65798d28..3e5045d5 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 @@ -569,7 +569,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey", "IsPrimary"); - b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); b.ToTable("UAuth_UserIdentifiers", (string)null); }); @@ -614,6 +614,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Tenant", "UserKey") .IsUnique(); + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + b.ToTable("UAuth_UserLifecycles", (string)null); }); 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 9f70b715236f9a65c34cbf1b8ccc03fdd59cac73..7ce3780f4c5345539db7569cb935fc3a17e5dc77 100644 GIT binary patch delta 942 zcmb8t$4^y37{=k>oVh0^1|ncLt}uuNHf|(h0TYpqVDDn@4N*XWiwz6bqs9heK`e;k zvG?A4xeJ&60mj6b7#HYw=4j$V7f$kKzIo@#+??bFgH|wTm3R0UkGDEX6hwq%&yh5L zPR;J95_Dwrm*k>WHD7N;y`RzR$VKaay*y=>_wJ8B)~~v$JXdwkmV@`3mZYtuog__? zA<2}qmvoS1Ig6}BXRFoONwUHs|8-jx^ZPyTn>tDUdUld^r8_B*-q%?e2)ZYd;*=iA z5+^@Ur&D+*d#T$zFr@TYicC3}_g-mRXokBg<+ak|C^lEAgpB{58dCNN8aXS+!{KR6 zVg~amWi=b6DR94K2sp`UTm#a{XC@0MV+~avuv>D0>oV23wK;K0J(RINJcG&1Vj<Rb#D&2`GHuaqj_v#7E_tSVk%h2 z77yCtY-6`fbvl8=2HRb@#7(cVvl&I9Ov~L*V}h15IZ&osaZieRyAMY=CO$^lFkX$y{g4f8aTr_nTze#9HidB{yg(xceb;~z+s%CiSr)RZMZLcy&k}GpY_0D zuZ%HDx8;GkOX7up*OMLWGk4+bn7}mVvYeHy7gep3dFiV-cCz1B?U~4Q=COiRY%ppd puY48HE~<@kJ`^>m*QRwFOF~M$@#lt8`7e&3!12C!5N~BK{RtAX1MmO< delta 980 zcmb7?S4Po=B~fOA0@czo?n0q$ofX zZq{&>H)pwOo2^}ieg9531U47Oe%Df>(xQLXW|Tg)Xc_6+H*6qkC~9m)=~;EHO(DUW z-k#xRuAc7Gge%_@5o5IVKn6`U(MuDkJi@Gnq+BuCt}q^7}vN(ft4KA z%4Mu$1DO)1xtr0I-V7p+@l2K^*7_Eatr_WTWQzjAiKY*OiO0hf8}Kc}cJ`9Z1F2H2 zHLZd*<2YHoRCom4=*tii@G{j*jvVc?gMHlQp@LVpkvp8=l@#gj=5(hYF^nadX_CYm zg_*&8yQkM;rIo7(X+9M>$#q^!nC5Ol5Bf8dL?$p@4Y--f0yR*ZRcgSu5l>4dK9C~5z$Hd5uTa((HZ(`*2+|%<{OB`0q*kHp3+*&)1wz*sq7g> z9a2bhu*&S>AUWjPm`^0@*~C`y33^eMDY!1D$aCNzc5{e(JW+5PGT6*E8#aouoHgRo zje4Bsje}QV4~MzWQz^A`%|fT^ z$1z^0ZV2ai?}XKu!y;wf{&c1%0~pOXCJML8Xvld!I9xDuS?q8n=|V3CGKM53DXtM0 x_~>xena2`k&7Am5{UZ6Qu(pnsKj;!S_#|~D&B{!YuilPfrhVg3F7sJj;xB%GJ1qbJ 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 9f63271774146adb23c75c9e808ba5aaed583609..6e7926c1937d74200f4d2350ffb3db1ae02c4d25 100644 GIT binary patch delta 14345 zcmeHO30PA{yG{rRNgz4F4P05o4LOkIoa977Nlua?h-^hcMHY8dMBJ?wT&n(c0T(=o ztxBzRX;lrySXxbJ%BkVBkQYC~`Td;jO&=6R9_X7bK_^DXau zXXZ=s?U3T1NJ^J2n!&@bc=C7(9{Y0ly=Rz&t$fhzLOZ_$DQ*VX#7EmbwslQECjaRA zk{qSi);>2eMIFGsqu2ZjVZeftXoXl|Ykldxhqv$a{qIVuGB09Xc~NSmT8pccxKi(} zR(4V8x~O%5luk!zNKI#@vWrsr+xbR!AD>1XHC-vmQ6j^hOMhlMaGrSrCle@|Izy0i zogAn1(cY1hecvl7jz$2J&wllY44diCiY<#`7sS0nmVBY4mIS1_^21B0)Eul+7uezD z4?mi;fFF(J@IwIKHpK+?!FXvAW~mIMP7Ag>8JSiicH|_$)euQC%v&LJcyV)EdSyr*aHJS)jOf#`JIg{O^fj_j+o8o?C>ELLA_f*mTkShD&KGKK$Y zUxmWyNDw!L3`KmKcrxpY4w+BEad$of8F+Nj<`Lbdm2nUZg^W3$rM#EkGPhKXA|x-q z9yxv1y-pm$9Yye-`5pUsd_)CM#-flN@5O(!XWQ2sIY?s^GI&g$)8$<+kAajd2(kHf z`^sbd!(QFzfJrmZ=FR=?z51*ZE(Lju(FrbXIfxeuae9>4ZO7#N^&m%s zLb5(6DBN;*%@7XagF?Q*uk{Yuwz&cn$Dxp)4(w0|Y?zzQLE55_yJ?lTyS}rw22_nj zA&aAShd;i2{1^vmheD1dc!<=8Bd!9)QWVnL_1qPA{}YQjNCyI1fh^u<*Q!a?>?fultd9% zzwFcZRls5qhftvi+3=3dvJPU;fifP2v>$c&ZX$1`l!Itch?tOXrWWr~fRynNVyt7L zpO+W6k$Yu6l}S>03Vt*X3zW~4os+c^Zxsi+%n_Xy;ldftJDuf%p-!uu#QbPkuv>!b zTImVNM@xJbO$U{;1zW}Z-Aut^fzEY?nihLZ@1Wz)bZ4Rj!T=^{wgB@;HVqy*c--JY zhH=Ss*5Fj@Up)pfMf3ShnQMHZ`FFo@DNjH!1Du47yaK#^ZKL?Ye{2J>|MVCHMmq^3 zoxsw?f@&ul2i%kP^&dL%}_T=};Z{^hxtw`#`-El54+uV$@K$uTode7A`ka-qDv~}+OxJbfGo4YWA-P|zep*(D${E*yNmhQI7b+@Zn zN{C;HTf6v*MhXjsPR>K&mTzztO!w@;eE-rpm>Q&r6f*?^_!le0?|qe>y_0R2dg9i%)Bh@EsDupUuaDWy zI|B8cWT#U=m(p*V)vucMMEm@n9GcVdV6Q%0UnJUhENh(0g3^-7TwgD=FXq7m-Djm? z0X84{Wf2OQRF7dae z|5Tt-qb8Lk1&3~i4*!Yx=WC+#%X~q~Bw+<+ab$c5w+qp#EtlGz|7uE+HN`IGVZ|`s zKEtx3L_Rz^UXq+Vc6i3nk%Nnokn8uHSC(;qf)cm(M(K<*;P_LaW_^9T^{@ zSvR*yo)$IG|BS+=5tuX=m3;1t!lKe|F3CY&fp8?|XQ4@x%3vgMqd{lH37uJs8ws@m zN>0%R6QQP+H0N>Q>xGRY-t@S)Sl#w4+6Z0R2lKO>r6I#5+|nEo0K!e^5MQ1SE(fB|u92+(=gQIM?aAGCJ>O4=K+QR{xt$@ndnZZbPGI#5@hOa{cr4Vk zv?=e|of9nt_#uC*^DW?E3!u}_#H3jgpGdKU^|;qz*y~NDIJhXg$EpcOl8%=>{m>Us z646?@pH5@eQKZF;n>88@PH2=AZq({XTtljLW~I?Wn$&7mh7|nG>EB`#W}&C3Sp%&e z-=G=lxB5>(s*0Wm%xhVNI>AuJwgB%i;aV)aqadE|$XcB`2;wCU>ebF!KE|6y4v>b& zS!X`P!>X}EtTS0(;QdabyRXk*oQKNK$VTa|gB-mG3HN5pEA-13;D>>7fTS2>YL18! zYzb^{@16pEx8|XsI8o%HXl-+$8Eq5K9$x>C2M~#2SCC%Fb=$Iaqv&?e(S4>=Jl#ofqYGE9LU$eNXVD}CKCK3n!F_x9MaW)c4*mU zeuqzfbTU@A`WBdbOXQ3Bk!BU8SE=+ksU>wdVIeKJL8&sbsh`ng(olLGMKSzO z&JwXBi>P&YN5q0FuuIubM@b^ECwaN0U0smm<8X_MHv&E{dICHnTzboVDN?DXSS1oB zoS<|x%-3l%uB2%~YoG~}Rz+HsxGTZMJ>X3x*2l0`{W7bLxOn{k+Ts%t{c!YGpMJQm z`ZL>z^OMdBxA}Xp?&6HWcuuO@r_TsxBZ-tAC!pD{kOl^8% zo9BluuGDHv9>iq>@*v27UUJFUy2mT#&OC8vW}mm4#DJKH;{k^hYM%-GpHD699wpoD zyzuNrw)q24w%aAw%}-Bj4HmTto+@eeG)|Z-@Ki~q)x)DDGhtCt2Ay7Mv2w=8X8pK|M9eKw4(~tXKhnH@fmOthZsQCfqqt#0u{S*ImY$^~}qs{3Z-ups2w2KGu zevURDdQzks&r?nSl$+Q^(bmRZZF?7owP;eIs8y_NmyNw-yC8q9mq~1-Q_ElB()uH@?z~6jWSKwlcDF=5nJ3iz$6<#MVZHNQI0>o9Wej2k$r_q{fGf$X^)>5HKZ^9{+ z#$uq%W{RS?M06P?ZqqP6tfIsoe-mzfe$SB^^PhIilk^u5?yS7WR}-JCImYsW2yCxQSd6QOdnma|1;z|12L(OF$P-R_ zWq%J~oFC3MIc5V{BT`$wCqLpT6`hS`p>lEyjh>!-*;vAJaD|V1kvJ^SrhMF z$>R3_c}1>J@pff&uZD{^u1@NRm3BiBqcR`gIYOVrIfPvmUCp`!|F~Msqu4_>7lpgc zghRlV(`keKp0Za@YcS^LX_CPnJi|_SLDZEcU%xAs$BF22M zSJK%w*xrJD8T^}{O#@|n(RJb@-^-fRs_+@>#ZWUOI^6d8KH9A>H{D_T&~@JRokKsX ztafMbd!J#g4W`H*Y}H0>!A0F2MnS8JV}q)jRRZrjo=^t|rCi_H6-0YUQGwTW);&Jx z-1!z579>TyqVN3KGomM+n+I~bOMg$lY`wHizpiah8lrEs@B;-=Qm8DuQkC+b7w+G3 zspJf&NV`&%8|JRxRbERw&BeW4-78O3?Uwm?_%2YDEqxzr^roU1MU-?v{l>QwE-&cO ziVr7*z5^cTz9C$&?@ZRSXo|PPlR3Zj=RBwCdNFsc}v1B>ZJ=>`V7kDbGP@ znN(h%gv)ZJ1O6L=-%g#Z7`Nv-0TPbCA-HSKkKbqhaLo^7R=pv(G;Z6bm)Gxq#9n;m zU`(j1*gLoA))d^K!m|HmtDjo7i;bsOfVvJj+BOPX-CUay9g6>2lC*U$#sWKmOu zNlC-f5bIqjd-yCut$rPM3C=sUVT(1Yx;iipM7Kdl$a*h!h2@UZWpLcv74g3qVfw;< zr3DORHx_6WyAtA+1@Gr%E<3fCa~WTBxDHp+O3M$7d(4$eRHwMQF|M8>Z&vcQ@NFZc zqut**T0lL4rcHIN!ffBME46PqBdJO8_#YR7uraPNm>;bnNWGROpqi83w)^4R@@t;>Qy|w3!UsvwmY7OeajtEhEBH_r4 zP$7242s^y&!=*>p264>p4f|~YzJFJ*m}N!Rzh(OnL#>;2AWye{$eI}sKWj7ObLgIiR3Sak=Bp!5cdBh$QI zMpzwpzFx)~S~;=bcX)jkr_S44~dG!vm*OOzB|zn6Q4y# zCx?&GYbM7>#0=~ctICKS6+0$kG+b{h__kSWaFfD*$ZKMCVGE2gDg74CEbqtA?wbz zAry%tS~WZBRmm|c`_m{}A8*n3Oc(XKN>HBVw%tmY%y-x10qj!js?j&K>QnS}tB{Kg zi-%xzu8|eIj7Y=Ng7VWCs(|2vH z5q>&sHwgWwTLl*Epdmt0DT3OI(y#w~F}lyffdt^R@YAaA%c# z=c5iPWsXxm1Havj!4Na(pEs-4G;49TBIx0)wJztwyPStjOtk&={%<_4y0=@qD3|cs z)%!jst^+Kop0Z%~S?;moXfao8Z5_IEpv$FYnZt{Rc~@bC%$rn%$fO~1GFVQkNu@#> zNDv_e@$Mk^1lOpoGOq>2xdhTz+Uxac-i{lC;0O-lCTs{|*A1S(d0Aw7)V|xrJEIZ6 zY4L=KrP<^5(y|I8=~-BQLKkf%REvTJIWr@QRfBRdzfkzVD*%2qc86b$MDWW2eynW7 zF?if<4=?P1)3}ZqG#(N{q96%EMk-_ksY)OSGNEgF>gd!#BS|8d2qq;EOABc2V4d5m zSFIIQE%>mFiQQ&kw~Nc7D=Ky(kXbg&-b1ob%%3i@X^Y1P?{jRvlUMV%Ehe#hK*@ zm7SK2I6d+DgL7u?ol$_7ETXCN+{kvtMURmoT>EdHI9~sxk7WoUir{lM-bwRlv;v%S zVIq)@mv?j-zAF9i76^ty)?>8`Jw92v(Ho8N95Og?Qlpc)h| zLhr}HfcH}r662iq^_bNeb!yTQh3w-V?%HzU#zGL? zh=r^O@g{ZY0&P??QF)_XR2c4bPBy%tCt6q5P5Y%lw2j1h=G zMIj%hQu}L8Oq^?hv_&D^|CSP#Gw#_>AZ#}ZA$tT)S13ZmED(PbqV(}S(Qo_mMwW8OOR)@kL3h1EO5`X4IE`Jm|3Lq976geRj$g^Gde-sBgMf%b zA%pI^kLL}ESe^) z5Vqi@aBp)%IkTK@IVl{|*oWC34n0`=S#0J|LA>*NemNtL@3E*XI+CB4tmC*%P%`sd zQ{*#t8!GGxn@bUo*&=FIp+ghu*IKrLih078QL`SiUD^J5?x9r7b9NihV}`>lu8`w4 zC{L>Axb=}U^HQiCHEdt%WCxazYWE0s@VW+e)mF)JqjDEGu&KmH> zS|A-z$Pal`L2b>qUxVnTY(#zVG0vFyVE@GyNGB8`ZXA5b5I(p7~^IFi%lCD_>ug(OwqtQlH#1GhkQDC8BVG~vl{0S8o$MIq~_4tP60Cd%Ie zF`$quzWLuSzoRb(VhV)_LbAV25N)mVYMoKYn8BrQ1+!z`f-rzW=FKxC9QDl2vy9Re zg>>%Ft7d$<`vZ{q9SSK~@zIWx3*KBeL*x-CWZI7-;$NOReu@rp0uI5zl0}C^9ASI2 zYV#iO=qGm=`ZhRRr3&32*qPuXr%dNtdVX{6>=vqPEP ztPk}}1J&jsJCYerc`I3-X65bpkll&`580i#4bXO8Lu?Ct^_WdK(vheeI@fv2w|@HR z+h%cQ9-7iQRb-vW+S}7VgwrZxouBc}x!s0wiPu2k5=SI! z_{wugtDiZ;pMg!A9H(Kvq*|%eE2LUn3CF-?QlkOasuXJ6C{+?_B`HxDWTaX0340w| zepqstq}RKHW2-717zxb>TO_xqe~1o8CI7Z}>^S#;D6~p7 z&1E9qAR4Jh@lH+u4GYy`^RB)hg|(Ol5~uQtu@rk|$7<^k$NM8I>gRm&hk!20&B2LO zbd9pMx41KHPt7w>IRR}R{;YIW*?~2$LFqx>PK=7?bKJ+w4T^XQFXU1W3PuUHt6?Hmd+ zucA8UyD>Ia<&ifQ%=`&$4lB5mF|XzZ7Zm*+ZH|3>^7L0*`x2RnM%4f!xLp_5XTbD{X@2>GFzIjS8wGKve*z73Ql~ zD|Je(21ZFjsla6hr4-i?8V#<}=m;YrGpIDs>5OvcO10J%l>|%V617~a%x3cAp!#!R z_Qtg*IGVNF&fq)IZClFiUq0DyaUb@`X<9^k1cA$E6QvEyH(qi%v?iN&2(Wr&vn@Y- z{OxqtH~nis;v`P3$X7<_)JBa`j>{C#Kg;AA39gn%6}VbqkSHWN9chrMXubldm9sf5 zyS-0Zo|xGPvIaNhrd=i!g{IG*2yLd%&J3WSRfy}v;cW+5=(9&`6Mgmw0%f*I6=J8W z9whFS-vourIY{8Ve796G;$FW3uxSgYm#~#lt#hZzstS$vGPL(Te7A1`LRCwuzRgKn}2-HkquF_ zZ((BtX!kn6>|mqJ4i@Ip|8U9Je~l+@|3{L5q{)D|-oE@Ss5phH;ZRS0_5!NYDNuV2 zZN4%*Yvz$o(tUtijW!Ry>@`hu*j)g;UqMgoWA((w=LZyT)-o=e*-~0OF{XsRxGm;L z(rExS$eD{bz%AS4g7H-~9$)W&RRp42a0>*!Qlm;@kg8NLSsQh2Ym}Mo z(bsH`_UMEoZI6Gx;r_MRdt~pVlH^7QUx3{9TqJ(DeL1JVW#4=SSket`PQ0aHoY~N? z5bW%aHfPSh6!~D&t+n99NG?a*+G@Br#_uTK^}Hm^Of1`KxELFn?h}!tDRH7rH;BXk z6YO~P_@YLOrrwwgUd-iII{PYgN`=m#lH(fq2faTgaE(r@g#nO+dwU5flN(919e=cx z>)9|n&Rhz^o;^zOKS%K&vaKHQCpZeX_So*CO_l9weRjf}>l}r%*;V{1GVP7*Q9CEv zRoJ6G4f82E|Hge8q~r}|IioJyws}cdcF+01ZyV|+ic3m31+lZPfv^K;bG|D7`Lv~m zr6B$c+U&dL@E2j-I{1Uk>(HtATAfPz-T5<*FADn3vZ$4(KVketO{BC>WYidC^k`K|pAMv4Z&VqSIvkc7 z;U-Y4SK}&Dr@{$^TqTp}RM2Co-_I{4!3udxHCUqfudB>8I_lGw zydbOmPiEw7sy(_9o?5kpv$8f1>3(;zK+tF=sLU5!$EdXJyg=)-(XZEJ3wK`^m2^ZA zQQ7;|VXyCePM22Urp*tIZ(X0-u2={Vr}0TL+>FG79$e9`z>nX!~xzc0VLk$eD(GGc83oXj9m{3 zSE7(x$vxFq2CsN&f!Lg{j#+&%uMcaNC%ySbD{fIfpS=FcIU=5}#I41sNjG>YR*g8_ zrS;Lkw43ul?F~4L^|HgPkqUqQhStsb;PDOKNsPMihS$N0Nh5|#-`#ChHSm7RdxX^& z(cw}kNkS>5&oJq`9ab$4iz?gNh&^YMMT;AI@$0qt;4U%a-ki$*Ali$MP;+B3wQi4B zcvFzui4UovPZX@(&7D->7hjt=o_>bT)BVP*Cih>(KclPBFR_*yePq|}5N0|NsVg!~ zQjZsTcRH{b>`dSnVlAy<#43A#CNIv%&5bT4P~|)LzSLt8A2}$nz6U$Dnd8jnn*CpH zxct&wCZHze@Z+q6F;5g1?73B11}bxG1a3O~QdLc_hkoEi0Xp|4FZ#Xu{8#r_ATHwn z7Qgfb8Y>~qo;04+c)%kL@Y_j~WpkGFvZ=UV)BB8%EkxR!C9S67QYZCqS!ywSn;Q|= zp0E2QApF%HkXgx}in+c|6jii?Kk&ngZzCLk!l(HGTP7X@Gi%=yZpy>LqYjJjE}@Bm zg4+o9$>q-`kLcaMYkh6GM-v+f%R*erZcRFcfpgx@q7O<~Si>LqPe^`-kL?)Gt9Ssa z2sCX_`2_ApW6fO z0Ai5ymzXbfT}GoyC84*Zbj*;$%$ATzak)YxlWHY;B`MXI5>NWr!^vjdr|7B+5s`KvwKW-lnJS9)*`_b3ur-S(TQTf4SN*Y4}SJ-Ab?>qvlp~y}5i=J4e&W z3teiZ*U@Hq8s}a7Y`{+*Q|QB*ZDs_IZ$7JG@T@%1$(z*~1@UH!nze5`8RTkF`Ql{b zcl0^(>?VE0JRidua9wC0%T+_|*U4cSn9wRSoitaI*-W}F!kpieZ=z78HU+tW4d!sb@jvA^$fn0amKdT=63 z&_vWlPUz%XtzH3(`bH?7l+H;sYArkoht+NJAoK6jOeK}rW4t4 zmM!4Nx<=JQCa=(XT>9Bel+m79LbT0Ywh*srujcL;=0!(Wrm&15EQ3`5AWWX#p6S9! zrdL*BGc6Mq;I^9f_tPzA;{q@K`Ps?UE>ltNHD|N_dpxquO36 zS?e37nKpNsUj6j>h_tYhv+uaw5OkOo`D+Ut!@lqU@fQTU&90f&l_1)#ZEiVbJn|nh zpxS3w!{lj?j43#3$b#q7OB%fkXB^AP)>wTMpFWX$la(Dh-u6QU- zDbWiNFB~oRad-bb@FHl|Q+Nq$pov0%SRWWO+`Ia0eVaQNp^om!my$mq0s3DqgZZI1|A>=INXSl2A38E6 zAt5O#iJI!p{m3qlrw&#@%aux%zhl29kJp+739xZOnfd(V>dZ(p0m2Oo;RZ;+3h0D- zTb+>3;)HmAg%kRn`F+3%)y>axLkGGp>(u{%e=>O4*g(Gv&3Z0byi`TUVkWkWf$f4+ zY{YW9JNm@wc=bzT&tm_Mps;rvGdOBU-L-mO*)j95ml@b)_y+P*B7=4y|Gu)sU*J@zcs1?8PQk+}AZvba3GeAKD@uL^l~& z5!Oj`6ZTXtk`HuvHyI=qI;ob_lek__!01lM6}ZNrRN-29h6)P~Qn}PfCB{1iDiT;U zlnITcE|0V){WIl9|5Or6Tf9xizhuACwP^5y-ZV?5n;bL1xePw6UeP5;JEkKs-Q%!F z_=e@K^quv(EG+!c!w9!FU+~Q`!jj%&>=y9u&O3@xeOB@?rUS)v6}0m4h7c>J^l)mI zhmq-{eizf$y>5uF2DUAzP=QeM>!TRhsFK-ELosUUQm1xQ7~Er1nP+)~X~*#q9K|LH zF7(N(@%~=@s~MZdz|ydD*h;7(378{6r^57wq(CPJ68@b@s4rgG)L8e5Ih&h?gG8v}sIOc05TvLDVWYBcI%|?j>H&vF8C~EPRR${hj zszr%KQ=yFMB1HoGU0o!k3Rr10k~nEl!wr!}qQzA@ogCMyWjdKcua?1k74(&d!2gdX z;0sBxF@YE9Z>!3PHf1#3rqg!%tQ{IbjM)h6A;Wam{^wguw?DGg3*0@-C%*W>CG&L3 zOxA*xV{|sX%WN<%Y>e&0VAi{EvjMg7h6Gp*=sYiJY{-#c7c{h+wk_&x${+u@vMT1v z&*mO9(55gAv?{sTs+erbh0p0!Z&$R|+St`f&H2~9LH&Kq_)yxeLM@gRh*6J70qF$q zI{#z)q8Hr8zKV}j4yf3VEjM=zFANOFl(dDxUfEiKgu}Me&S+0S_hIEM;z|z}-_!hd zLhK-PrW+X82J9Yo0TRCiGd5*9kjyyEn9;)$dZd{41+f_!qqA8M-h^k<4mVe>4i5gd zFK4sdSPoJqRW_g5s5uR?`86wXMab3*uH60f&9UalURWlFm&6om1#VQ)XPia}+zT5? zHBM-B@CLAygue-AtiSqYxkn}ok;sCT@C+2*aEABD8osfXS;OjYZmiFbEKS+%IsFPv z9}8Q`z?OnqoL7KR{@Zz2jy+v1D#gjYpL65jsM=?%ESpx3}CFV`70T6@H= zXT5VBTlK8JmoB~k$eImWA5p9F1aETW>9L*16C;!KQJQ#ZucT2~Lp3p32@%7L>WHxk zJqLHmj2%2Ep&Oa18K3=EZ zk0uoG#W{AY)w^nBPc{ox#AefQ<9KM`!)~X(H>r_xDBLu_FIta|Mh7}4PanoY>lf36 zuzt~iRkBCn>XpfIO#wCG_eiyB42W7S*v$N|*xQ6#Gd;}kq=>)LlQ zhtGCCS1$y1hSnJH9OupY>z$xY8E7tkXJ1F|7^PX3)eLMkR!)o3raNz;DO7hdrM+}$ zwkZw8oL+=G2JZDX<2p02&LHuEpabUEAI9VqQ<6#r{UCsMp8L@a=HjlYctm$)Q#=~g z{zg*MtG++h)A28g1~&Dl&}!9KV7^BAB z;EAbM{v2=W=4K~HmZiQ;&)%k9)n%&WyMEoWuD>_?zu1S_?DmuUFD%Y9;d|m^9lw2rfLq9CX5!TF27{mANKQ#kX5D)J`39dD+SmV zm}{-V0t47SIwZaQZ4ansQ3rLxX0m@=o!eCLP44P%ycA}Bjxn%fpjIl}Z%U}ebSG}V zQIpW3BKu@b*gURudYZYHISgzL@a`$h!*W8{KFNs04TujcHH4yhP=kv2mWqkH`9F_{ j82fyj{PmPDvv|`P*mP6TWIUqE92QbPu^fe>b*KLW_ho#0 diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs new file mode 100644 index 00000000..7846f35d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IUAuthPaginationPolicy +{ + int DefaultPageSize { get; } + int MaxPageSize { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs index 1dc6aed7..4dd85c89 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -1,30 +1,29 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Contracts; public record PageRequest { public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = 250; + public int PageSize { get; init; } = 0; public string? SortBy { get; init; } public bool Descending { get; init; } - public int MaxPageSize { get; init; } = 1000; - - public PageRequest Normalize() + public PageRequest Normalize(IUAuthPaginationPolicy policy) { - var page = PageNumber <= 0 ? 1 : PageNumber; - var size = PageSize <= 0 ? 250 : PageSize; + ArgumentNullException.ThrowIfNull(policy); + + var page = Math.Max(1, PageNumber); + var size = PageSize <= 0 ? policy.DefaultPageSize : PageSize; - if (size > MaxPageSize) - size = MaxPageSize; + size = Math.Min(size, policy.MaxPageSize); - return new PageRequest + return this with { PageNumber = page, - PageSize = size, - SortBy = SortBy, - Descending = Descending, - MaxPageSize = MaxPageSize + PageSize = size }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs index 854eafa4..bfe06bcf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -9,7 +9,7 @@ public sealed class PagedResult public string? SortBy { get; init; } public bool Descending { get; init; } - public bool HasNext => PageNumber * PageSize < TotalCount; + public bool HasNext => (long)PageNumber * PageSize < TotalCount; public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending) { diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index ac273a50..69702225 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -47,4 +47,9 @@ public sealed class UAuthOptions /// validated, and optionally enforced. /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + /// + /// Pagination configuration for user queries, role listings, and other paged endpoints. + /// + public UAuthPaginationOptions Pagination { get; set; } = new(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs new file mode 100644 index 00000000..f88e98dc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Options; + +public sealed class UAuthPaginationOptions : IUAuthPaginationPolicy +{ + public const int DefaultPageSizeValue = 250; + public const int DefaultMaxPageSizeValue = 1000; + + public int DefaultPageSize { get; set; } = DefaultPageSizeValue; + + public int MaxPageSize { get; set; } = DefaultMaxPageSizeValue; + + internal UAuthPaginationOptions Clone() => new() + { + DefaultPageSize = DefaultPageSize, + MaxPageSize = MaxPageSize + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index dfa550da..ab9cca3f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -63,7 +63,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s .PostConfigure(options => { // Add any default values or adjustments here if needed - }); + }) + .ValidateOnStart(); services.AddUltimateAuthServerInternal(); @@ -81,7 +82,8 @@ public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollect { configure?.Invoke(options); }) - .BindConfiguration("UltimateAuth:ResourceApi"); + .BindConfiguration("UltimateAuth:ResourceApi") + .ValidateOnStart(); services.AddUltimateAuthResourceInternal(); @@ -133,8 +135,15 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + services.AddSingleton, UAuthServerPaginationOptionsValidator>(); services.AddScoped(); + services.TryAddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return options.Pagination; + }); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 97652d24..7e91f86b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -43,6 +43,11 @@ public sealed class UAuthServerOptions /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + /// + /// Controls the default and maximum page sizes for paginated queries. + /// + public UAuthPaginationOptions Pagination { get; set; } = new(); + // ------------------------------------------------------- // SERVER-ONLY BEHAVIOR // ------------------------------------------------------- @@ -139,9 +144,10 @@ internal UAuthServerOptions Clone() Pkce = Pkce.Clone(), Events = Events.Clone(), MultiTenant = MultiTenant.Clone(), + Pagination = Pagination.Clone(), + Cookie = Cookie.Clone(), Diagnostics = Diagnostics.Clone(), - PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs new file mode 100644 index 00000000..dfa3079e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerPaginationOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + + if (options.Pagination is null) + { + errors.Add("Pagination configuration cannot be null."); + return ValidateOptionsResult.Fail(errors); + } + + var pagination = options.Pagination; + + if (pagination.DefaultPageSize <= 0) + { + errors.Add("Pagination.DefaultPageSize must be greater than zero."); + } + + if (pagination.MaxPageSize <= 0) + { + errors.Add("Pagination.MaxPageSize must be greater than zero."); + } + + if (pagination.DefaultPageSize > pagination.MaxPageSize) + { + errors.Add("Pagination.DefaultPageSize cannot exceed Pagination.MaxPageSize."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index c993e957..ff3b6e77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -10,12 +10,14 @@ internal sealed class SessionApplicationService : ISessionApplicationService { private readonly IAccessOrchestrator _accessOrchestrator; private readonly ISessionStoreFactory _storeFactory; + private readonly IUAuthPaginationPolicy _pagination; private readonly IClock _clock; - public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock) + public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IUAuthPaginationPolicy pagination, IClock clock) { _accessOrchestrator = accessOrchestrator; _storeFactory = storeFactory; + _pagination = pagination; _clock = clock; } @@ -24,7 +26,7 @@ public async Task> GetUserChainsAsync(AccessCon var command = new AccessCommand>(async innerCt => { var store = _storeFactory.Create(context.ResourceTenant); - request = request.Normalize(); + request = request.Normalize(_pagination); var chains = await store.GetChainsByUserAsync(userKey); var actorChainId = context.ActorChainId; diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs index 9412811b..3f2e9820 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -10,11 +11,13 @@ internal sealed class EfCoreRoleStore : IRoleStore where TDbContext { private readonly TDbContext _db; private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant) + public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) { _db = db; _tenant = tenant.Tenant; + _pagination = pagination; } private DbSet DbSetRole => _db.Set(); @@ -218,7 +221,7 @@ public async Task> GetByIdsAsync( // TODO: Add UltimateAuth standard: tiebreaker for sorting fields that are not unique. public async Task> QueryAsync(RoleQuery query, CancellationToken ct = default) { - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = DbSetRole .AsNoTracking() diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs index d6bf6afe..a26c9be1 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; @@ -6,14 +7,16 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory where TDbContext : DbContext { private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreRoleStoreFactory(TDbContext db) + public EfCoreRoleStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) { _db = db; + _pagination = pagination; } public IRoleStore Create(TenantKey tenant) { - return new EfCoreRoleStore(_db, new TenantExecutionContext(tenant)); + return new EfCoreRoleStore(_db, new TenantExecutionContext(tenant), _pagination); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs index 78075797..e7f81625 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -9,9 +10,11 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore, IRoleStore { protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + private readonly IUAuthPaginationPolicy _pagination; - public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) + public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext) { + _pagination = pagination; } protected override void BeforeAdd(Role entity) @@ -67,7 +70,7 @@ public Task> QueryAsync(RoleQuery query, CancellationToken ct { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = TenantValues().AsQueryable(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs index b51d4f2a..346cf043 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; @@ -8,14 +9,16 @@ public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory { private readonly ConcurrentDictionary _stores = new(); private readonly InMemoryAtomicContextAccessor _atomicContext; + private readonly IUAuthPaginationPolicy _pagination; - public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext) + public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) { _atomicContext = atomicContext; + _pagination = pagination; } public IRoleStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext)); + return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext, _pagination)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 224b2a9b..251eeb77 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -12,13 +12,15 @@ internal sealed class UserRoleService : IUserRoleService private readonly IAccessOrchestrator _accessOrchestrator; private readonly IUserRoleStoreFactory _userRoleFactory; private readonly IRoleStoreFactory _roleFactory; + private readonly IUAuthPaginationPolicy _pagination; private readonly IClock _clock; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IClock clock) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IUAuthPaginationPolicy pagination, IClock clock) { _accessOrchestrator = accessOrchestrator; _userRoleFactory = userRoleFactory; _roleFactory = roleFactory; + _pagination = pagination; _clock = clock; } @@ -76,7 +78,7 @@ public async Task> GetRolesAsync(AccessContext context var cmd = new AccessCommand>(async innerCt => { - request = request.Normalize(); + request = request.Normalize(_pagination); var roleStore = _roleFactory.Create(context.ResourceTenant); var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStore.cs new file mode 100644 index 00000000..abf8f9dc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStore.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public interface IUserSummaryQueryStore +{ + Task> QueryAsync(UserQuery query, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStoreFactory.cs new file mode 100644 index 00000000..195d7e64 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStoreFactory.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public interface IUserSummaryQueryStoreFactory +{ + IUserSummaryQueryStore Create(TenantKey tenant); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs index 596f089d..d3efa64d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -4,6 +4,10 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserQuery : PageRequest { + /// + /// Searches users by display name, username, primary email or primary phone using case-insensitive partial matching. + /// UserKey is matched only when the search term represents a valid, complete UserKey; partial UserKey matching is not supported. + /// public string? Search { get; set; } public UserStatus? Status { get; set; } public bool IncludeDeleted { get; set; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs index d5d6763d..f011d9ce 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs @@ -51,9 +51,9 @@ private static void ConfigureIdentifiers(ModelBuilder b) e.HasIndex(x => new { x.Tenant, x.Type, x.NormalizedValue }).IsUnique(); e.HasIndex(x => new { x.Tenant, x.UserKey }); - e.HasIndex(x => new { x.Tenant, x.UserKey, x.Type, x.IsPrimary }); e.HasIndex(x => new { x.Tenant, x.UserKey, x.IsPrimary }); e.HasIndex(x => new { x.Tenant, x.NormalizedValue }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Type, x.IsPrimary, x.DeletedAt, x.CreatedAt, x.Id }); }); } @@ -86,6 +86,8 @@ private static void ConfigureLifecycles(ModelBuilder b) .IsRequired(); e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.DeletedAt, x.CreatedAt, x.Id }); + e.HasIndex(x => new { x.Tenant, x.Status, x.DeletedAt, x.CreatedAt, x.Id }); }); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs index fdc5cee9..423a098a 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.EntityFrameworkCore.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +20,7 @@ public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore>(); services.AddScoped>(); services.AddScoped>(); + services.AddScoped>(); return services; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs index 68669465..33705d26 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; @@ -7,14 +8,16 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserProfileStoreFactory : IUserProfileStoreFactory where TDbContext : DbContext { private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserProfileStoreFactory(TDbContext db) + public EfCoreUserProfileStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) { _db = db; + _pagination = pagination; } public IUserProfileStore Create(TenantKey tenant) { - return new EfCoreUserProfileStore(_db, new TenantExecutionContext(tenant)); + return new EfCoreUserProfileStore(_db, new TenantExecutionContext(tenant), _pagination); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index cac3ada2..2974b556 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -12,11 +13,13 @@ internal sealed class EfCoreUserIdentifierStore : IUserIdentifierSto { private readonly TDbContext _db; private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserIdentifierStore(TDbContext db, TenantExecutionContext tenant) + public EfCoreUserIdentifierStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) { _db = db; _tenant = tenant.Tenant; + _pagination = pagination; } private DbSet DbSet => _db.Set(); @@ -305,7 +308,7 @@ public async Task> QueryAsync(UserIdentifierQuery qu if (query.UserKey is null) throw new UAuthIdentifierValidationException("userKey_required"); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = DbSet .AsNoTracking() diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs index cd0be9fc..35385e57 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; @@ -7,14 +8,16 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserIdentifierStoreFactory : IUserIdentifierStoreFactory where TDbContext : DbContext { private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserIdentifierStoreFactory(TDbContext db) + public EfCoreUserIdentifierStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) { _db = db; + _pagination = pagination; } public IUserIdentifierStore Create(TenantKey tenant) { - return new EfCoreUserIdentifierStore(_db, new TenantExecutionContext(tenant)); + return new EfCoreUserIdentifierStore(_db, new TenantExecutionContext(tenant), _pagination); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs index bd617ef4..31ce250c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; @@ -10,11 +11,13 @@ internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore { private readonly TDbContext _db; private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserLifecycleStore(TDbContext db, TenantExecutionContext tenant) + public EfCoreUserLifecycleStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) { _db = db; _tenant = tenant.Tenant; + _pagination = pagination; } private DbSet DbSet => _db.Set(); @@ -130,7 +133,7 @@ public async Task> QueryAsync(UserLifecycleQuery quer { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = DbSet .AsNoTracking() @@ -147,6 +150,9 @@ public async Task> QueryAsync(UserLifecycleQuery quer nameof(UserLifecycle.Id) => query.Descending ? baseQuery.OrderByDescending(x => x.Id) : baseQuery.OrderBy(x => x.Id), + nameof(UserLifecycle.UserKey) => + query.Descending ? baseQuery.OrderByDescending(x => x.UserKey) : baseQuery.OrderBy(x => x.UserKey), + nameof(UserLifecycle.CreatedAt) => query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt), diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs index 38fb3af7..c0a4ce65 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; @@ -7,14 +8,16 @@ namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; internal sealed class EfCoreUserLifecycleStoreFactory : IUserLifecycleStoreFactory where TDbContext : DbContext { private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserLifecycleStoreFactory(TDbContext db) + public EfCoreUserLifecycleStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) { _db = db; + _pagination = pagination; } public IUserLifecycleStore Create(TenantKey tenant) { - return new EfCoreUserLifecycleStore(_db, new TenantExecutionContext(tenant)); + return new EfCoreUserLifecycleStore(_db, new TenantExecutionContext(tenant), _pagination); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs index 32bef99f..9a0ebbe3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -13,11 +14,13 @@ internal sealed class EfCoreUserProfileStore : IUserProfileStore whe { private readonly TDbContext _db; private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreUserProfileStore(TDbContext db, TenantExecutionContext tenant) + public EfCoreUserProfileStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) { _db = db; _tenant = tenant.Tenant; + _pagination = pagination; } private DbSet DbSet => _db.Set(); @@ -136,7 +139,7 @@ public async Task> QueryAsync(UserProfileQuery query, C { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = DbSet .AsNoTracking() diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs new file mode 100644 index 00000000..453043e0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs @@ -0,0 +1,194 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserSummaryQueryStore : IUserSummaryQueryStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; + + public EfCoreUserSummaryQueryStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) + { + _db = db; + _tenant = tenant.Tenant; + _pagination = pagination; + } + + public async Task> QueryAsync(UserQuery query, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(_pagination); + var profileKey = query.ProfileKey ?? ProfileKey.Default; + + var lifecycles = _db.Set() + .AsNoTracking() + .Where(x => x.Tenant == _tenant); + + if (!query.IncludeDeleted) + { + lifecycles = lifecycles.Where(x => x.DeletedAt == null); + } + + if (query.Status.HasValue) + { + lifecycles = lifecycles.Where(x => x.Status == query.Status.Value); + } + + var profiles = _db.Set() + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.ProfileKey == profileKey && + x.DeletedAt == null); + + var identifiers = _db.Set() + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.DeletedAt == null && + x.IsPrimary); + + var summaries = lifecycles.Select(l => new UserSummary + { + UserKey = l.UserKey, + Status = l.Status, + CreatedAt = l.CreatedAt, + + DisplayName = profiles + .Where(p => p.UserKey == l.UserKey) + .Select(p => p.DisplayName) + .FirstOrDefault(), + + UserName = identifiers + .Where(i => + i.UserKey == l.UserKey && + i.Type == UserIdentifierType.Username) + .OrderBy(i => i.CreatedAt) + .ThenBy(i => i.Id) + .Select(i => i.Value) + .FirstOrDefault(), + + PrimaryEmail = identifiers + .Where(i => + i.UserKey == l.UserKey && + i.Type == UserIdentifierType.Email) + .OrderBy(i => i.CreatedAt) + .ThenBy(i => i.Id) + .Select(i => i.Value) + .FirstOrDefault(), + + PrimaryPhone = identifiers + .Where(i => + i.UserKey == l.UserKey && + i.Type == UserIdentifierType.Phone) + .OrderBy(i => i.CreatedAt) + .ThenBy(i => i.Id) + .Select(i => i.Value) + .FirstOrDefault() + }); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + + // Escaped substring matching. + // Database collation determines case sensitivity. + var pattern = "%" + EscapeLike(search) + "%"; + var userKey = UserKey.FromString(search); + + summaries = summaries.Where(x => + x.UserKey == userKey || + + (x.DisplayName != null && + EF.Functions.Like( + x.DisplayName, pattern, "\\")) || + (x.UserName != null && + EF.Functions.Like( + x.UserName, pattern, "\\")) || + (x.PrimaryEmail != null && + EF.Functions.Like( + x.PrimaryEmail, pattern, "\\")) || + (x.PrimaryPhone != null && + EF.Functions.Like( + x.PrimaryPhone, pattern, "\\"))); + } + + var total = await summaries.CountAsync(ct); + + var ordered = query.SortBy switch + { + nameof(UserSummary.DisplayName) => + query.Descending + ? summaries.OrderByDescending(x => x.DisplayName).ThenBy(x => x.CreatedAt) + : summaries.OrderBy(x => x.DisplayName).ThenBy(x => x.CreatedAt), + + nameof(UserSummary.UserName) => + query.Descending + ? summaries.OrderByDescending(x => x.UserName).ThenBy(x => x.CreatedAt) + : summaries.OrderBy(x => x.UserName).ThenBy(x => x.CreatedAt), + + nameof(UserSummary.PrimaryEmail) => + query.Descending + ? summaries.OrderByDescending(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt) + : summaries.OrderBy(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt), + + nameof(UserSummary.PrimaryPhone) => + query.Descending + ? summaries.OrderByDescending(x => x.PrimaryPhone).ThenBy(x => x.CreatedAt) + : summaries.OrderBy(x => x.PrimaryPhone).ThenBy(x => x.CreatedAt), + + nameof(UserSummary.CreatedAt) => + query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.CreatedAt).ThenBy(x => x.UserKey), + + _ => query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.CreatedAt).ThenBy(x => x.UserKey) + }; + + var offset = + ((long)normalized.PageNumber - 1) * + normalized.PageSize; + + if (offset > int.MaxValue) + { + return new PagedResult( + Array.Empty(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + var items = await ordered + .Skip((int)offset) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items, + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + private static string EscapeLike(string value) + { + return value + .Replace("\\", "\\\\") + .Replace("%", "\\%") + .Replace("_", "\\_"); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStoreFactory.cs new file mode 100644 index 00000000..5259f3e2 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStoreFactory.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserSummaryQueryStoreFactory : IUserSummaryQueryStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; + + public EfCoreUserSummaryQueryStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) + { + _db = db; + _pagination = pagination; + } + + public IUserSummaryQueryStore Create(TenantKey tenant) + { + return new EfCoreUserSummaryQueryStore(_db, new TenantExecutionContext(tenant), _pagination); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs index 7e8527bb..6017bf9e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.InMemory.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.DependencyInjection; @@ -13,6 +14,7 @@ public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 25e10750..6c26db5c 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -12,10 +13,11 @@ public sealed class InMemoryUserIdentifierStore : InMemoryTenantVersionedStore entity.Id; private readonly object _primaryLock = new(); + private readonly IUAuthPaginationPolicy _pagination; - public InMemoryUserIdentifierStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) + public InMemoryUserIdentifierStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext) { - + _pagination = pagination; } public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) @@ -143,7 +145,7 @@ public Task> QueryAsync(UserIdentifierQuery query, C if (query.UserKey is null) throw new UAuthIdentifierValidationException("userKey_required"); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = TenantValues() .Where(x => x.UserKey == query.UserKey.Value); diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index f3e37415..5bf7253a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.Reference; @@ -10,15 +11,18 @@ public sealed class InMemoryUserLifecycleStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.UserKey); - public InMemoryUserLifecycleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) + private readonly IUAuthPaginationPolicy _pagination; + + public InMemoryUserLifecycleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext) { + _pagination = pagination; } public Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = TenantValues().AsQueryable(); if (!query.IncludeDeleted) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs index 68cea827..348d3d23 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -13,8 +14,11 @@ public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore new(entity.Tenant, entity.UserKey, entity.ProfileKey); - public InMemoryUserProfileStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) + private readonly IUAuthPaginationPolicy _pagination; + + public InMemoryUserProfileStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext) { + _pagination = pagination; } protected override void BeforeAdd(UserProfile entity) @@ -32,7 +36,7 @@ public Task> QueryAsync(UserProfileQuery query, Cancell { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = TenantValues().AsQueryable(); if (query.ProfileKey != null) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs new file mode 100644 index 00000000..b737c21c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs @@ -0,0 +1,182 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSummaryQueryStore : IUserSummaryQueryStore +{ + private readonly IUserLifecycleStore _lifecycles; + private readonly IUserProfileStore _profiles; + private readonly IUserIdentifierStore _identifiers; + private readonly IUAuthPaginationPolicy _pagination; + + public InMemoryUserSummaryQueryStore(IUserLifecycleStore lifecycles, IUserProfileStore profiles, IUserIdentifierStore identifiers, IUAuthPaginationPolicy pagination) + { + _lifecycles = lifecycles; + _profiles = profiles; + _identifiers = identifiers; + _pagination = pagination; + } + + public async Task> QueryAsync(UserQuery query, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + + var normalized = query.Normalize(_pagination); + var profileKey = query.ProfileKey ?? ProfileKey.Default; + + const int batchSize = 500; + + var summaries = new List(); + var page = 1; + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var result = await _lifecycles.QueryAsync( + new UserLifecycleQuery + { + PageNumber = page, + PageSize = batchSize, + SortBy = nameof(UserLifecycle.CreatedAt), + Status = query.Status, + IncludeDeleted = query.IncludeDeleted + }, + ct); + + if (result.Items.Count == 0) + break; + + var keys = result.Items + .Select(x => x.UserKey) + .ToList(); + + var profiles = await _profiles.GetByUsersAsync( + keys, profileKey, ct); + + var identifiers = await _identifiers.GetByUsersAsync( + keys, ct); + + var profileMap = profiles.ToDictionary(x => x.UserKey); + + var identifierGroups = identifiers + .GroupBy(x => x.UserKey) + .ToDictionary(x => x.Key, x => x.ToList()); + + foreach (var lifecycle in result.Items) + { + profileMap.TryGetValue( + lifecycle.UserKey, out var profile); + + identifierGroups.TryGetValue( + lifecycle.UserKey, out var ids); + + summaries.Add(new UserSummary + { + UserKey = lifecycle.UserKey, + DisplayName = profile?.DisplayName, + + UserName = PrimaryValue( + ids, UserIdentifierType.Username), + + PrimaryEmail = PrimaryValue( + ids, UserIdentifierType.Email), + + PrimaryPhone = PrimaryValue( + ids, UserIdentifierType.Phone), + + Status = lifecycle.Status, + CreatedAt = lifecycle.CreatedAt + }); + } + + if (!result.HasNext) + break; + + page++; + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + var userKey = UserKey.FromString(search); + + summaries = summaries.Where(x => + x.UserKey == userKey || + Contains(x.DisplayName, search) || + Contains(x.UserName, search) || + Contains(x.PrimaryEmail, search) || + Contains(x.PrimaryPhone, search) + ).ToList(); + } + + IEnumerable ordered = query.SortBy switch + { + nameof(UserSummary.DisplayName) => + query.Descending + ? summaries + .OrderByDescending(x => x.DisplayName) + .ThenBy(x => x.UserKey.Value) + : summaries + .OrderBy(x => x.DisplayName) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.CreatedAt) => + query.Descending + ? summaries + .OrderByDescending(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value) + : summaries + .OrderBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + _ => summaries + .OrderBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value) + }; + + var total = summaries.Count; + + var offset = + ((long)normalized.PageNumber - 1) * + normalized.PageSize; + + var items = offset > int.MaxValue + ? new List() + : ordered + .Skip((int)offset) + .Take(normalized.PageSize) + .ToList(); + + return new PagedResult( + items, + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + private static bool Contains(string? value, string search) + { + return value?.Contains( + search, + StringComparison.InvariantCultureIgnoreCase) ?? false; + } + + private static string? PrimaryValue( + IReadOnlyList? identifiers, + UserIdentifierType type) + { + return identifiers? + .Where(x => x.Type == type && x.IsPrimary) + .OrderBy(x => x.CreatedAt) + .ThenBy(x => x.Id) + .Select(x => x.Value) + .FirstOrDefault(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStoreFactory.cs new file mode 100644 index 00000000..4888f4a8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStoreFactory.cs @@ -0,0 +1,27 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserSummaryQueryStoreFactory : IUserSummaryQueryStoreFactory +{ + private readonly IUserLifecycleStoreFactory _lifecycles; + private readonly IUserProfileStoreFactory _profiles; + private readonly IUserIdentifierStoreFactory _identifiers; + private readonly IUAuthPaginationPolicy _pagination; + + public InMemoryUserSummaryQueryStoreFactory(IUserLifecycleStoreFactory lifecycles, IUserProfileStoreFactory profiles, IUserIdentifierStoreFactory identifiers, IUAuthPaginationPolicy pagination) + { + _lifecycles = lifecycles; + _profiles = profiles; + _identifiers = identifiers; + _pagination = pagination; + } + + public IUserSummaryQueryStore Create(TenantKey tenant) + { + return new InMemoryUserSummaryQueryStore(_lifecycles.Create(tenant), _profiles.Create(tenant), _identifiers.Create(tenant), _pagination); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 3a8e4f04..902d61d8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -27,6 +27,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IUserIdentifierAvailabilityService _identifierAvailabilityService; private readonly ISessionStoreFactory _sessionStoreFactory; private readonly IUserIdentifierVerifier _identifierVerifier; + private readonly IUserSummaryQueryStoreFactory _queryStoreFactory; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -43,6 +44,7 @@ public UserApplicationService( IUserIdentifierAvailabilityService identifierAvailabilityService, ISessionStoreFactory sessionStoreFactory, IUserIdentifierVerifier ıdentifierVerifier, + IUserSummaryQueryStoreFactory queryStoreFactory, IOptions options, IClock clock) { @@ -58,6 +60,7 @@ public UserApplicationService( _identifierAvailabilityService = identifierAvailabilityService; _sessionStoreFactory = sessionStoreFactory; _identifierVerifier = ıdentifierVerifier; + _queryStoreFactory = queryStoreFactory; _options = options.Value; _clock = clock; } @@ -890,125 +893,19 @@ UserIdentifierType.Email or public async Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default) { - var command = new AccessCommand>(async innerCt => - { - query ??= new UserQuery(); - var effectiveProfileKey = query.ProfileKey ?? ProfileKey.Default; - - var lifecycleQuery = new UserLifecycleQuery - { - PageNumber = 1, - PageSize = int.MaxValue, - Status = query.Status, - IncludeDeleted = query.IncludeDeleted - }; - - var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); - var lifecycleResult = await lifecycleStore.QueryAsync(lifecycleQuery, innerCt); - var lifecycles = lifecycleResult.Items; - - if (lifecycles.Count == 0) - { - return new PagedResult( - Array.Empty(), - 0, - query.PageNumber, - query.PageSize, - query.SortBy, - query.Descending); - } - - var userKeys = lifecycles.Select(x => x.UserKey).ToList(); - var profileStore = _profileStoreFactory.Create(context.ResourceTenant); - var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); - var profiles = await profileStore.GetByUsersAsync(userKeys, effectiveProfileKey, innerCt); - var identifiers = await identifierStore.GetByUsersAsync(userKeys, innerCt); - var profileMap = profiles.ToDictionary(x => x.UserKey); - var identifierGroups = identifiers.GroupBy(x => x.UserKey).ToDictionary(x => x.Key, x => x.ToList()); - - var summaries = new List(); - - foreach (var lifecycle in lifecycles) + var command = new AccessCommand>( + async innerCt => { - profileMap.TryGetValue(lifecycle.UserKey, out var profile); + var normalized = (UserQuery)(query ?? new UserQuery()).Normalize(_options.Pagination); + var store = _queryStoreFactory.Create(context.ResourceTenant); - identifierGroups.TryGetValue(lifecycle.UserKey, out var ids); - - var username = ids?.FirstOrDefault(x => - x.Type == UserIdentifierType.Username && - x.IsPrimary); - - var email = ids?.FirstOrDefault(x => - x.Type == UserIdentifierType.Email && - x.IsPrimary); - - var phone = ids?.FirstOrDefault(x => - x.Type == UserIdentifierType.Phone && - x.IsPrimary); - - summaries.Add(new UserSummary - { - UserKey = lifecycle.UserKey, - DisplayName = profile?.DisplayName, - UserName = username?.Value, - PrimaryEmail = email?.Value, - PrimaryPhone = phone?.Value, - Status = lifecycle.Status, - CreatedAt = lifecycle.CreatedAt - }); - } - - // SEARCH - if (!string.IsNullOrWhiteSpace(query.Search)) - { - var search = query.Search.Trim().ToLowerInvariant(); - - summaries = summaries - .Where(x => - (x.DisplayName?.ToLowerInvariant().Contains(search) ?? false) || - (x.PrimaryEmail?.ToLowerInvariant().Contains(search) ?? false) || - (x.PrimaryPhone?.ToLowerInvariant().Contains(search) ?? false) || - (x.UserName?.ToLowerInvariant().Contains(search) ?? false) || - x.UserKey.Value.ToLowerInvariant().Contains(search)) - .ToList(); - } - - // SORT - summaries = query.SortBy switch - { - nameof(UserSummary.DisplayName) => - query.Descending - ? summaries.OrderByDescending(x => x.DisplayName).ToList() - : summaries.OrderBy(x => x.DisplayName).ToList(), - - nameof(UserSummary.CreatedAt) => - query.Descending - ? summaries.OrderByDescending(x => x.CreatedAt).ToList() - : summaries.OrderBy(x => x.CreatedAt).ToList(), - - _ => summaries.OrderBy(x => x.CreatedAt).ToList() - }; - - var total = summaries.Count; - - // PAGINATION - var items = summaries - .Skip((query.PageNumber - 1) * query.PageSize) - .Take(query.PageSize) - .ToList(); - - return new PagedResult( - items, - total, - query.PageNumber, - query.PageSize, - query.SortBy, - query.Descending); - }); + return await store.QueryAsync(normalized, innerCt); + }); return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + private async Task EnsureIdentifierUniquenessAsync( IUserIdentifierStore store, UserIdentifierType type, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs index 013bb234..34935a76 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/EfCoreTestRuntime.cs @@ -94,8 +94,7 @@ public static async Task CreateAsync( private async Task InitializeDatabaseAsync() { - await using var scope = - Services.CreateAsyncScope(); + await using var scope = Services.CreateAsyncScope(); var db = scope.ServiceProvider diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs index 486a598b..e7099981 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserCreationAtomicityTests.cs @@ -1,6 +1,8 @@ -using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs new file mode 100644 index 00000000..c8d3b429 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs @@ -0,0 +1,241 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration.EfCore.Users; + +public sealed class UserQueryPaginationTests +{ + [Fact] + public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() + { + await using var runtime = await EfCoreTestRuntime.CreateAsync( + services => + { + services.PostConfigure(options => + { + options.Pagination.DefaultPageSize = 2; + options.Pagination.MaxPageSize = 3; + }); + }); + + var names = new[] + { + "Charlie", + "Alice", + "Echo", + "Bob", + "Delta" + }; + + var prefix = $"ef-pagination-{Guid.NewGuid():N}"; + + // + // Arrange: persist five users through the real service. + // + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider + .GetRequiredService(); + + var createContext = TestAccessContext.ForUserCreation( + TestUsers.Admin, + UAuthActions.Users.CreateAnonymous); + + foreach (var name in names) + { + var result = await service.CreateUserAsync( + createContext, + new CreateUserRequest + { + UserName = $"{prefix}-{name}", + DisplayName = $"{prefix}-{name}" + }); + + result.IsSuccess.Should().BeTrue(); + } + } + + // + // Act: use a fresh DI scope / DbContext. + // + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider + .GetRequiredService(); + + // Use the action registered for querying users. + var context = TestAccessContext.ForUserCreation( + TestUsers.Admin, + UAuthActions.Users.CreateAnonymous); + + var firstPage = await service.QueryUsersAsync( + context, + new UserQuery + { + PageNumber = 1, + PageSize = 0, + Search = prefix, + SortBy = nameof(UserSummary.DisplayName) + }); + + firstPage.TotalCount.Should().Be(5); + firstPage.PageNumber.Should().Be(1); + firstPage.PageSize.Should().Be(2); + + firstPage.Items + .Select(x => x.DisplayName) + .Should() + .Equal( + $"{prefix}-Alice", + $"{prefix}-Bob"); + + // + // MaxPageSize = 3. + // Requested PageSize = 100 must be clamped. + // + var secondPage = await service.QueryUsersAsync( + context, + new UserQuery + { + PageNumber = 2, + PageSize = 100, + Search = prefix, + SortBy = nameof(UserSummary.DisplayName) + }); + + secondPage.TotalCount.Should().Be(5); + secondPage.PageNumber.Should().Be(2); + secondPage.PageSize.Should().Be(3); + + secondPage.Items + .Select(x => x.DisplayName) + .Should() + .Equal( + $"{prefix}-Delta", + $"{prefix}-Echo"); + } + } + + [Fact] + public async Task QueryUsersAsync_AcrossPages_ReturnsEveryUserExactlyOnce() + { + await using var runtime = await EfCoreTestRuntime.CreateAsync( + services => + { + services.PostConfigure(options => + { + options.Pagination.DefaultPageSize = 2; + options.Pagination.MaxPageSize = 3; + }); + }); + + var names = new[] + { + "Charlie", + "Alice", + "Echo", + "Bob", + "Delta" + }; + + var prefix = $"ef-pages-{Guid.NewGuid():N}"; + + // Arrange: create five users in non-alphabetical order. + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider + .GetRequiredService(); + + var context = TestAccessContext.ForUserCreation( + TestUsers.Admin, + UAuthActions.Users.CreateAnonymous); + + foreach (var name in names) + { + var result = await service.CreateUserAsync( + context, + new CreateUserRequest + { + UserName = $"{prefix}-{name}", + DisplayName = $"{prefix}-{name}" + }); + + result.IsSuccess.Should().BeTrue(); + } + } + + // Act: query all three pages using a fresh DbContext. + using (var scope = runtime.Services.CreateScope()) + { + var service = scope.ServiceProvider + .GetRequiredService(); + + var context = TestAccessContext.ForUserCreation( + TestUsers.Admin, + UAuthActions.Users.CreateAnonymous); + + var pages = new List>(); + + for (var pageNumber = 1; pageNumber <= 3; pageNumber++) + { + var result = await service.QueryUsersAsync( + context, + new UserQuery + { + PageNumber = pageNumber, + PageSize = 2, + Search = prefix, + SortBy = nameof(UserSummary.DisplayName) + }); + + pages.Add(result); + } + + // Assert: pagination metadata. + pages.Should().HaveCount(3); + + pages.Select(x => x.TotalCount) + .Should() + .OnlyContain(x => x == 5); + + pages.Select(x => x.PageSize) + .Should() + .OnlyContain(x => x == 2); + + pages.Select(x => x.PageNumber) + .Should() + .Equal(1, 2, 3); + + pages.Select(x => x.Items.Count) + .Should() + .Equal(2, 2, 1); + + // Assert: deterministic ordering across pages. + var allUsers = pages + .SelectMany(x => x.Items) + .ToList(); + + allUsers.Select(x => x.DisplayName) + .Should() + .Equal( + $"{prefix}-Alice", + $"{prefix}-Bob", + $"{prefix}-Charlie", + $"{prefix}-Delta", + $"{prefix}-Echo"); + + // Assert: no duplicate or missing users. + allUsers.Should().HaveCount(5); + + allUsers.Select(x => x.UserKey) + .Distinct() + .Should() + .HaveCount(5); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index f9726fb1..2d97dd87 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -8,6 +8,8 @@ using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; using System.Net; using System.Net.Http.Json; @@ -518,6 +520,126 @@ await identifierFactory x.UserKey == userKey); } + [Fact] + public async Task QueryUsersAsync_AppliesConfiguredPaginationAndSorting() + { + using var factory = AuthServerFactory.Create( + configureServer: options => + { + options.Pagination.DefaultPageSize = 2; + options.Pagination.MaxPageSize = 3; + }, + configureServices: services => + { + var access = new Mock(); + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns< + AccessContext, + AccessCommand>, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + services.RemoveAll(); + services.AddScoped(_ => access.Object); + }); + + var prefix = $"pagination-{Guid.NewGuid():N}"; + + var names = new[] + { + "Charlie", + "Alice", + "Echo", + "Bob", + "Delta" + }; + + foreach (var name in names) + { + var user = await factory.CreateLoginUserAsync(); + + using var seedScope = factory.Services.CreateScope(); + + var profileFactory = seedScope.ServiceProvider + .GetRequiredService(); + + await profileFactory + .Create(TenantKeys.Single) + .AddAsync(UserProfile.Create( + Guid.NewGuid(), + TenantKeys.Single, + user.UserKey, + ProfileKey.Default, + factory.Clock.UtcNow, + displayName: $"{prefix}-{name}")); + } + + using var scope = factory.Services.CreateScope(); + + var service = scope.ServiceProvider + .GetRequiredService(); + + var actor = UserKey.New(); + + var context = new AccessContext( + actorUserKey: actor, + actorTenant: TenantKeys.Single, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "users", + targetUserKey: actor, + resourceTenant: TenantKeys.Single, + action: "test", + attributes: EmptyAttributes.Instance); + + // DefaultPageSize = 2 + var firstPage = await service.QueryUsersAsync( + context, + new UserQuery + { + PageNumber = 1, + PageSize = 0, + Search = prefix, + SortBy = nameof(UserSummary.DisplayName) + }); + + firstPage.TotalCount.Should().Be(5); + firstPage.PageNumber.Should().Be(1); + firstPage.PageSize.Should().Be(2); + firstPage.Items.Should().HaveCount(2); + + firstPage.Items + .Select(x => x.DisplayName) + .Should() + .Equal($"{prefix}-Alice", $"{prefix}-Bob"); + + // Requested 100 -> MaxPageSize = 3 + var secondPage = await service.QueryUsersAsync( + context, + new UserQuery + { + PageNumber = 2, + PageSize = 100, + Search = prefix, + SortBy = nameof(UserSummary.DisplayName) + }); + + secondPage.TotalCount.Should().Be(5); + secondPage.PageNumber.Should().Be(2); + secondPage.PageSize.Should().Be(3); + secondPage.Items.Should().HaveCount(2); + + secondPage.Items + .Select(x => x.DisplayName) + .Should() + .Equal($"{prefix}-Delta", $"{prefix}-Echo"); + } // ------------------------------------------------------- // Helpers diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs index c16a3583..75b33a5a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs @@ -1,18 +1,25 @@ -using CodeBeam.UltimateAuth.Authorization; + +using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Contracts.Authorization; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using static MudBlazor.CategoryTypes; namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; public sealed class EfCoreRoleStoreContractTests : RoleStoreContractTests { + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + protected override async Task CreateDatabaseAsync() { - var database = new EfRoleStoreTestDatabase(); + var database = new EfRoleStoreTestDatabase(_pagination); + await database.InitializeAsync(); return database; @@ -23,14 +30,17 @@ private sealed class EfRoleStoreTestDatabase : IRoleStoreTestDatabase private readonly SqliteConnection _connection; private readonly DbContextOptions _options; private readonly List _contexts = []; + private readonly IUAuthPaginationPolicy _pagination; - public EfRoleStoreTestDatabase() + public EfRoleStoreTestDatabase(IUAuthPaginationPolicy pagination) { _connection = new SqliteConnection("Data Source=:memory:"); _options = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; + + _pagination = pagination; } public async Task InitializeAsync() @@ -38,6 +48,7 @@ public async Task InitializeAsync() await _connection.OpenAsync(); await using var db = new UAuthAuthorizationDbContext(_options); + await db.Database.EnsureCreatedAsync(); } @@ -49,7 +60,8 @@ public IRoleStore CreateStore(TenantKey tenant) return new EfCoreRoleStore( db, - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), + _pagination); } public async ValueTask DisposeAsync() @@ -62,4 +74,4 @@ public async ValueTask DisposeAsync() await _connection.DisposeAsync(); } } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs index 5bc8596b..94bf1c16 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs @@ -1,22 +1,31 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.InMemory; -using CodeBeam.UltimateAuth.Tests.Unit.Helpers; namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization; public sealed class InMemoryRoleStoreContractTests : RoleStoreContractTests { + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + protected override Task CreateDatabaseAsync() { return Task.FromResult( - new InMemoryRoleStoreTestDatabase()); + new InMemoryRoleStoreTestDatabase(_pagination)); } private sealed class InMemoryRoleStoreTestDatabase : IRoleStoreTestDatabase { private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + private readonly IUAuthPaginationPolicy _pagination; + + public InMemoryRoleStoreTestDatabase(IUAuthPaginationPolicy pagination) + { + _pagination = pagination; + } public IRoleStore CreateStore(TenantKey tenant) { @@ -24,10 +33,10 @@ public IRoleStore CreateStore(TenantKey tenant) return new InMemoryRoleStore( executionContext, - _atomicContext); + _atomicContext, _pagination); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs index 1cfcc08f..bc11a48b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs @@ -6,6 +6,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; @@ -489,11 +490,10 @@ public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize() new PageRequest { PageNumber = 1, - PageSize = 5000, - MaxPageSize = 100 + PageSize = 5000 }); - result.PageSize.Should().Be(100); + result.PageSize.Should().Be(1000); } [Fact] @@ -952,6 +952,8 @@ private sealed class Fixture public Mock RoleStore { get; } = new(MockBehavior.Strict); + public UAuthPaginationOptions PaginationPolicy { get; } = new(); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -996,6 +998,7 @@ public Fixture() AccessOrchestrator.Object, UserRoleFactory.Object, RoleFactory.Object, + PaginationPolicy, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/PaginationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/PaginationTests.cs new file mode 100644 index 00000000..6654a14a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/PaginationTests.cs @@ -0,0 +1,142 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core; + +public sealed class PaginationTests +{ + private readonly UAuthPaginationOptions _pagination = new() + { + DefaultPageSize = 25, + MaxPageSize = 100 + }; + + [Fact] + public void Normalize_WhenPageNumberIsInvalid_UsesFirstPage() + { + var request = new PageRequest + { + PageNumber = -5, + PageSize = 10 + }; + + var result = request.Normalize(_pagination); + + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(10); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Normalize_WhenPageSizeIsInvalid_UsesConfiguredDefault(int pageSize) + { + var request = new PageRequest + { + PageSize = pageSize + }; + + var result = request.Normalize(_pagination); + + result.PageSize.Should().Be(25); + } + + [Fact] + public void Normalize_WhenPageSizeExceedsMaximum_ClampsToConfiguredMaximum() + { + var request = new PageRequest + { + PageSize = 500 + }; + + var result = request.Normalize(_pagination); + + result.PageSize.Should().Be(100); + } + + [Fact] + public void Normalize_WhenPageSizeEqualsMaximum_PreservesValue() + { + var request = new PageRequest + { + PageSize = 100 + }; + + var result = request.Normalize(_pagination); + + result.PageSize.Should().Be(100); + } + + [Fact] + public void Normalize_WhenValuesAreValid_PreservesValues() + { + var request = new PageRequest + { + PageNumber = 3, + PageSize = 50, + SortBy = "Name", + Descending = true + }; + + var result = request.Normalize(_pagination); + + result.PageNumber.Should().Be(3); + result.PageSize.Should().Be(50); + result.SortBy.Should().Be("Name"); + result.Descending.Should().BeTrue(); + } + + [Fact] + public void Normalize_DoesNotMutateOriginalRequest() + { + var request = new PageRequest + { + PageNumber = -1, + PageSize = 500 + }; + + var result = request.Normalize(_pagination); + + request.PageNumber.Should().Be(-1); + request.PageSize.Should().Be(500); + + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(100); + + result.Should().NotBeSameAs(request); + } + + [Fact] + public void Normalize_PreservesDerivedQueryType() + { + var request = new UserQuery + { + PageNumber = 0, + PageSize = 500, + Search = "admin" + }; + + var result = request.Normalize(_pagination); + + result.Should().BeOfType(); + + var normalized = (UserQuery)result; + + normalized.PageNumber.Should().Be(1); + normalized.PageSize.Should().Be(100); + normalized.Search.Should().Be("admin"); + } + + [Fact] + public void Normalize_WhenPaginationPolicyIsNull_Throws() + { + var request = new PageRequest(); + + var act = () => request.Normalize(null!); + + act.Should().Throw(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs index 92147b93..79d725a7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.Data.Sqlite; @@ -23,8 +24,9 @@ public async Task Add_And_Get_Should_Work() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); + var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create( null, @@ -49,7 +51,7 @@ public async Task Add_With_Duplicate_Name_Should_Throw() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); var role2 = Role.Create(null, tenant, "ADMIN", null, DateTimeOffset.UtcNow); @@ -69,7 +71,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -77,7 +79,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); await store.SaveAsync(updated, expectedVersion: 0); @@ -85,7 +87,7 @@ public async Task Save_Should_Increment_Version() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Equal(1, result!.Version); @@ -103,7 +105,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -111,7 +113,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); @@ -131,7 +133,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); var role2 = Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow); role1Id = role1.Id; @@ -142,7 +144,7 @@ public async Task Rename_To_Existing_Name_Should_Throw() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = await store.GetAsync(new RoleKey(tenant, role2Id)); var updated = role!.Rename("admin", DateTimeOffset.UtcNow); @@ -160,7 +162,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create( null, @@ -176,7 +178,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store.GetAsync(new RoleKey(tenant, roleId)); var updated = existing!.SetPermissions( new[] @@ -189,7 +191,7 @@ public async Task Save_Should_Replace_Permissions() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.Single(result!.Permissions); @@ -207,7 +209,7 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); roleId = role.Id; await store.AddAsync(role); @@ -215,13 +217,13 @@ public async Task Soft_Delete_Should_Work() await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store.DeleteAsync(new RoleKey(tenant, roleId), 0, DeleteMode.Soft, DateTimeOffset.UtcNow); } await using (var db = CreateDb(connection)) { - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store.GetAsync(new RoleKey(tenant, roleId)); Assert.NotNull(result!.DeletedAt); } @@ -234,7 +236,7 @@ public async Task Query_Should_Filter_And_Page() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreRoleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store.AddAsync(Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow)); await store.AddAsync(Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs index c980329d..f06f5f60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -23,7 +24,7 @@ public async Task Add_And_Get_Should_Work() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -49,7 +50,7 @@ public async Task Exists_Should_Return_True_When_Exists() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( @@ -77,7 +78,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); await using var db1 = CreateDb(connection); - var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var identifier = UserIdentifier.Create( Guid.NewGuid(), @@ -92,7 +93,7 @@ public async Task Save_With_Wrong_Version_Should_Throw() await store1.AddAsync(identifier); await using var db2 = CreateDb(connection); - var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var updated = identifier.SetPrimary(DateTimeOffset.UtcNow); @@ -120,13 +121,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(identifier); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(identifier.Id); var updated = existing!.SetPrimary(DateTimeOffset.UtcNow); await store2.SaveAsync(updated, expectedVersion: 0); @@ -134,7 +135,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserIdentifierStore(db3, new TenantExecutionContext(tenant)); + var store3 = new EfCoreUserIdentifierStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store3.GetAsync(identifier.Id); Assert.Equal(1, result!.Version); } @@ -147,8 +148,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() await using var db = CreateDb(connection); var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant1)); - var store2 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant2)); + var store1 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant1), new UAuthPaginationOptions()); + var store2 = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant2), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -174,7 +175,7 @@ public async Task Soft_Delete_Should_Work() using var connection = CreateOpenConnection(); await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserIdentifierStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); var identifier = UserIdentifier.Create( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs index f07ef861..9c97641c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; @@ -23,7 +24,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -48,7 +49,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -78,13 +79,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); await store2.SaveAsync(updated, expectedVersion: 0); @@ -92,7 +93,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); Assert.Equal(1, result!.Version); @@ -115,13 +116,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); @@ -139,8 +140,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant1)); - var store2 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant2)); + var store1 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant1), new UAuthPaginationOptions()); + var store2 = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant2), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -163,7 +164,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserLifecycleStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -201,13 +202,13 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserLifecycleStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(lifecycle); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserLifecycleStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); var deleted = existing!.MarkDeleted(DateTimeOffset.UtcNow); @@ -217,7 +218,7 @@ public async Task Delete_Should_Increment_SecurityVersion() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant)); + var store3 = new EfCoreUserLifecycleStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs index 0a154f9b..7da74d8d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; @@ -24,7 +25,7 @@ public async Task Add_And_Get_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -53,7 +54,7 @@ public async Task Exists_Should_Return_True_When_Exists() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -95,13 +96,13 @@ public async Task Save_Should_Increment_Version() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(profile); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); await store2.SaveAsync(updated, expectedVersion: 0); @@ -109,7 +110,7 @@ public async Task Save_Should_Increment_Version() await using (var db3 = CreateDb(connection)) { - var store3 = new EfCoreUserProfileStore(db3, new TenantExecutionContext(tenant)); + var store3 = new EfCoreUserProfileStore(db3, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var result = await store3.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); Assert.Equal(1, result!.Version); @@ -138,13 +139,13 @@ public async Task Save_With_Wrong_Version_Should_Throw() await using (var db1 = CreateDb(connection)) { - var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant)); + var store1 = new EfCoreUserProfileStore(db1, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); await store1.AddAsync(profile); } await using (var db2 = CreateDb(connection)) { - var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant)); + var store2 = new EfCoreUserProfileStore(db2, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey, ProfileKey.Default)); var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); @@ -162,8 +163,8 @@ public async Task Should_Not_See_Data_From_Other_Tenant() var tenant1 = TenantKeys.Single; var tenant2 = TenantKey.FromInternal("tenant-2"); - var store1 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant1)); - var store2 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant2)); + var store1 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant1), new UAuthPaginationOptions()); + var store2 = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant2), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -191,7 +192,7 @@ public async Task Soft_Delete_Should_Work() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -227,7 +228,7 @@ public async Task Same_User_Can_Have_Multiple_Profiles() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -265,7 +266,7 @@ public async Task GetAsync_Should_Return_Correct_Profile_By_ProfileKey() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -298,7 +299,7 @@ public async Task GetByUsersAsync_Should_Filter_By_ProfileKey() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -333,7 +334,7 @@ public async Task Should_Not_Allow_Duplicate_ProfileKey_For_Same_User() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); @@ -364,7 +365,7 @@ public async Task Delete_Should_Not_Affect_Other_Profiles() await using var db = CreateDb(connection); var tenant = TenantKeys.Single; - var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreUserProfileStore(db, new TenantExecutionContext(tenant), new UAuthPaginationOptions()); var userKey = UserKey.FromGuid(Guid.NewGuid()); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index fe385ea1..25bedf8d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -359,4 +359,137 @@ public void Valid_session_resolution_should_pass() var options = provider.GetRequiredService>().Value; options.SessionResolution.EnableBearer.Should().BeTrue(); } + + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Pagination_default_page_size_must_be_positive(int value) + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Pagination.DefaultPageSize = value; + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerPaginationOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + Action act = () => + _ = provider.GetRequiredService>().Value; + + act.Should() + .Throw() + .WithMessage("*Pagination.DefaultPageSize*"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Pagination_max_page_size_must_be_positive(int value) + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Pagination.MaxPageSize = value; + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerPaginationOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + Action act = () => + _ = provider.GetRequiredService>().Value; + + act.Should() + .Throw() + .WithMessage("*Pagination.MaxPageSize*"); + } + + [Fact] + public void Pagination_default_page_size_exceeding_maximum_should_fail() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Pagination.DefaultPageSize = 200; + o.Pagination.MaxPageSize = 100; + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerPaginationOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + Action act = () => + _ = provider.GetRequiredService>().Value; + + act.Should() + .Throw() + .WithMessage("*Pagination.DefaultPageSize*"); + } + + [Fact] + public void Valid_pagination_options_should_pass() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Pagination.DefaultPageSize = 50; + o.Pagination.MaxPageSize = 100; + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerPaginationOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + var options = provider + .GetRequiredService>() + .Value; + + options.Pagination.DefaultPageSize.Should().Be(50); + options.Pagination.MaxPageSize.Should().Be(100); + } + + [Fact] + public void Pagination_default_page_size_equal_to_maximum_should_pass() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Pagination.DefaultPageSize = 100; + o.Pagination.MaxPageSize = 100; + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerPaginationOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + var options = provider + .GetRequiredService>() + .Value; + + options.Pagination.DefaultPageSize.Should().Be(100); + options.Pagination.MaxPageSize.Should().Be(100); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs index 1ab99a58..eae2f55f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Sessions.InMemory; @@ -749,6 +750,7 @@ private static (SessionApplicationService Sut, ISessionStore Store) CreateSut() var sut = new SessionApplicationService( new PassThroughAccessOrchestrator(), factory, + new UAuthPaginationOptions(), new TestClock(Now)); return (sut, store); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs index 18f5b5f5..557935bc 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.Core.Options; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; @@ -358,6 +359,6 @@ private static InMemoryUserIdentifierStore CreateStore() { return new InMemoryUserIdentifierStore( new TenantExecutionContext(TenantKeys.Single), - new InMemoryAtomicContextAccessor()); + new InMemoryAtomicContextAccessor(), new UAuthPaginationOptions()); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs index e0cca736..c73c01d3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs @@ -1,17 +1,19 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -public sealed class EfCoreUserIdentifierStoreContractTests - : UserIdentifierStoreContractTests +public sealed class EfCoreUserIdentifierStoreContractTests : UserIdentifierStoreContractTests { - protected override async Task - CreateDatabaseAsync() + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + + protected override async Task CreateDatabaseAsync() { - var db = new Database(); + var db = new Database(_pagination); await db.InitializeAsync(); return db; } @@ -20,8 +22,9 @@ private sealed class Database : IUserIdentifierStoreTestDatabase { private readonly SqliteConnection _connection; private readonly UAuthUserDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public Database() + public Database(IUAuthPaginationPolicy pagination) { _connection = new SqliteConnection("Data Source=:memory:"); @@ -31,6 +34,7 @@ public Database() .Options; _db = new UAuthUserDbContext(options); + _pagination = pagination; } public async Task InitializeAsync() @@ -43,7 +47,7 @@ public IUserIdentifierStore CreateStore(TenantKey tenant) { return new EfCoreUserIdentifierStore( _db, - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), _pagination); } public async ValueTask DisposeAsync() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs index d6ddf889..9774f006 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Data.Sqlite; @@ -6,13 +8,13 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; -public sealed class EfCoreUserLifecycleStoreContractTests - : UserLifecycleStoreContractTests +public sealed class EfCoreUserLifecycleStoreContractTests : UserLifecycleStoreContractTests { - protected override async Task - CreateDatabaseAsync() + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + + protected override async Task CreateDatabaseAsync() { - var database = new Database(); + var database = new Database(_pagination); await database.InitializeAsync(); return database; } @@ -21,8 +23,9 @@ private sealed class Database : IUserLifecycleStoreTestDatabase { private readonly SqliteConnection _connection; private readonly UAuthUserDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public Database() + public Database(IUAuthPaginationPolicy pagination) { _connection = new SqliteConnection( "Data Source=:memory:"); @@ -33,6 +36,7 @@ public Database() .Options; _db = new UAuthUserDbContext(options); + _pagination = pagination; } public async Task InitializeAsync() @@ -45,7 +49,7 @@ public IUserLifecycleStore CreateStore(TenantKey tenant) { return new EfCoreUserLifecycleStore( _db, - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), _pagination); } public async ValueTask DisposeAsync() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs index 9534cd8e..d58aebdc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Data.Sqlite; @@ -6,13 +8,13 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; -public sealed class EfCoreUserProfileStoreContractTests - : UserProfileStoreContractTests +public sealed class EfCoreUserProfileStoreContractTests : UserProfileStoreContractTests { - protected override async Task - CreateDatabaseAsync() + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + + protected override async Task CreateDatabaseAsync() { - var database = new Database(); + var database = new Database(_pagination); await database.InitializeAsync(); return database; } @@ -21,8 +23,9 @@ private sealed class Database : IUserProfileStoreTestDatabase { private readonly SqliteConnection _connection; private readonly UAuthUserDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public Database() + public Database(IUAuthPaginationPolicy pagination) { _connection = new SqliteConnection("Data Source=:memory:"); @@ -33,6 +36,7 @@ public Database() .Options; _db = new UAuthUserDbContext(options); + _pagination = pagination; } public async Task InitializeAsync() @@ -45,7 +49,7 @@ public IUserProfileStore CreateStore(TenantKey tenant) { return new EfCoreUserProfileStore( _db, - new TenantExecutionContext(tenant)); + new TenantExecutionContext(tenant), _pagination); } public async ValueTask DisposeAsync() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs index e3f1deb1..8cd75a59 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; using CodeBeam.UltimateAuth.Users.InMemory; @@ -6,8 +7,7 @@ public sealed class InMemoryUserIdentifierStoreContractTests : UserIdentifierStoreContractTests { - protected override Task - CreateDatabaseAsync() + protected override Task CreateDatabaseAsync() { return Task.FromResult( new Database()); @@ -26,7 +26,7 @@ public IUserIdentifierStore CreateStore(TenantKey tenant) var store = new InMemoryUserIdentifierStore( new TenantExecutionContext(tenant), - _atomicContext); + _atomicContext, new UAuthPaginationOptions()); _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 aec76080..feac74d8 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.Reference; @@ -7,8 +9,7 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; public sealed class InMemoryUserLifecycleStoreContractTests : UserLifecycleStoreContractTests { - protected override Task - CreateDatabaseAsync() + protected override Task CreateDatabaseAsync() { return Task.FromResult( new Database()); @@ -19,6 +20,7 @@ private sealed class Database : IUserLifecycleStoreTestDatabase private readonly Dictionary _stores = []; private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); public IUserLifecycleStore CreateStore(TenantKey tenant) { @@ -27,7 +29,7 @@ public IUserLifecycleStore CreateStore(TenantKey tenant) var store = new InMemoryUserLifecycleStore( new TenantExecutionContext(tenant), - _atomicContext); + _atomicContext, _pagination); _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 e9046745..27a8b9e4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Users.InMemory; using CodeBeam.UltimateAuth.Users.Reference; @@ -17,8 +19,8 @@ protected override Task private sealed class Database : IUserProfileStoreTestDatabase { private readonly Dictionary _stores = []; - private readonly InMemoryAtomicContextAccessor _atomicContext = new(); + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); public IUserProfileStore CreateStore(TenantKey tenant) { @@ -27,7 +29,7 @@ public IUserProfileStore CreateStore(TenantKey tenant) var store = new InMemoryUserProfileStore( new TenantExecutionContext(tenant), - _atomicContext); + _atomicContext, _pagination); _stores.Add(tenant, store); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/EfCoreUserSummaryQueryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/EfCoreUserSummaryQueryStoreContractTests.cs new file mode 100644 index 00000000..fa570143 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/EfCoreUserSummaryQueryStoreContractTests.cs @@ -0,0 +1,145 @@ + +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class EfCoreUserSummaryQueryStoreContractTests : UserSummaryQueryStoreContractTests +{ + protected override async Task CreateDatabaseAsync() + { + var db = new Database(); + await db.InitializeAsync(); + return db; + } + + private sealed class Database : IUserSummaryQueryStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthUserDbContext _db; + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthUserDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IUserSummaryQueryStore CreateStore( + TenantKey tenant) + { + return new EfCoreUserSummaryQueryStore( + _db, + new TenantExecutionContext(tenant), + _pagination); + } + + public async Task SeedAsync( + TenantKey tenant, + IReadOnlyList users) + { + const int batchSize = 500; + + foreach (var batch in users.Chunk(batchSize)) + { + foreach (var user in batch) + { + _db.Lifecycles.Add(new UserLifecycleProjection + { + Id = Guid.NewGuid(), + Tenant = tenant, + UserKey = user.UserKey, + Status = user.Status, + CreatedAt = user.CreatedAt, + DeletedAt = user.IsDeleted + ? user.CreatedAt.AddMinutes(1) + : null + }); + + if (user.HasProfile) + { + _db.Profiles.Add(new UserProfileProjection + { + Id = Guid.NewGuid(), + Tenant = tenant, + UserKey = user.UserKey, + ProfileKey = user.ProfileKey, + DisplayName = user.DisplayName, + CreatedAt = user.CreatedAt + }); + } + + AddIdentifier( + tenant, + user, + UserIdentifierType.Username, + user.UserName); + + AddIdentifier( + tenant, + user, + UserIdentifierType.Email, + user.Email); + + AddIdentifier( + tenant, + user, + UserIdentifierType.Phone, + user.Phone); + } + + await _db.SaveChangesAsync(); + _db.ChangeTracker.Clear(); + } + } + + private void AddIdentifier( + TenantKey tenant, + UserSummarySeed user, + UserIdentifierType type, + string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + _db.Identifiers.Add(new UserIdentifierProjection + { + Id = Guid.NewGuid(), + Tenant = tenant, + UserKey = user.UserKey, + Type = type, + Value = value, + NormalizedValue = value.ToLowerInvariant(), + IsPrimary = true, + CreatedAt = user.CreatedAt + }); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/IUserSummaryQueryStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/IUserSummaryQueryStoreTestDatabase.cs new file mode 100644 index 00000000..67ade100 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/IUserSummaryQueryStoreTestDatabase.cs @@ -0,0 +1,32 @@ + +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public interface IUserSummaryQueryStoreTestDatabase : IAsyncDisposable +{ + IUserSummaryQueryStore CreateStore(TenantKey tenant); + Task SeedAsync(TenantKey tenant, IReadOnlyList users); +} + +public sealed record UserSummarySeed +{ + public required UserKey UserKey { get; init; } + + public UserStatus Status { get; init; } = UserStatus.Active; + + public required DateTimeOffset CreatedAt { get; init; } + + public string? DisplayName { get; init; } + public string? UserName { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } + + public ProfileKey ProfileKey { get; init; } = ProfileKey.Default; + + public bool IsDeleted { get; init; } + public bool HasProfile { get; init; } = true; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/InMemoryUserSummaryQueryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/InMemoryUserSummaryQueryStoreContractTests.cs new file mode 100644 index 00000000..8979c214 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/InMemoryUserSummaryQueryStoreContractTests.cs @@ -0,0 +1,156 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; +using static MudBlazor.CategoryTypes; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class InMemoryUserSummaryQueryStoreContractTests : UserSummaryQueryStoreContractTests +{ + protected override Task CreateDatabaseAsync() + { + return Task.FromResult(new Database()); + } + + private sealed class Database : IUserSummaryQueryStoreTestDatabase + { + private readonly InMemoryAtomicContextAccessor _atomic = new(); + + private readonly Dictionary _stores = []; + + private readonly IUAuthPaginationPolicy _pagination = new UAuthPaginationOptions(); + + private sealed record TenantStores( + IUserLifecycleStore Lifecycles, + IUserProfileStore Profiles, + IUserIdentifierStore Identifiers); + + + private TenantStores GetStores(TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var existing)) + return existing; + + var context = new TenantExecutionContext(tenant); + + var stores = new TenantStores( + new InMemoryUserLifecycleStore( + context, + _atomic, + _pagination), + new InMemoryUserProfileStore( + context, + _atomic, _pagination), + new InMemoryUserIdentifierStore( + context, + _atomic, _pagination)); + + _stores.Add(tenant, stores); + return stores; + } + + + public IUserSummaryQueryStore CreateStore(TenantKey tenant) + { + var stores = GetStores(tenant); + + return new InMemoryUserSummaryQueryStore( + stores.Lifecycles, + stores.Profiles, + stores.Identifiers, + _pagination); + } + + public async Task SeedAsync( + TenantKey tenant, + IReadOnlyList users) + { + var stores = GetStores(tenant); + + foreach (var user in users) + { + var lifecycle = UserLifecycle.Create( + tenant, + user.UserKey, + user.CreatedAt); + + if (user.Status != UserStatus.Active) + { + lifecycle.ChangeStatus( + user.CreatedAt, + user.Status); + } + + if (user.IsDeleted) + { + lifecycle.MarkDeleted( + user.CreatedAt.AddMinutes(1)); + } + + await stores.Lifecycles.AddAsync(lifecycle); + + if (user.HasProfile) + { + var profile = UserProfile.Create( + id: null, + tenant: tenant, + userKey: user.UserKey, + profileKey: user.ProfileKey, + createdAt: user.CreatedAt, + displayName: user.DisplayName); + + await stores.Profiles.AddAsync(profile); + } + + await AddIdentifierAsync( + stores.Identifiers, tenant, user, + UserIdentifierType.Username, + user.UserName); + + await AddIdentifierAsync( + stores.Identifiers, tenant, user, + UserIdentifierType.Email, + user.Email); + + await AddIdentifierAsync( + stores.Identifiers, tenant, user, + UserIdentifierType.Phone, + user.Phone); + } + } + + private static async Task AddIdentifierAsync( + IUserIdentifierStore store, + TenantKey tenant, + UserSummarySeed user, + UserIdentifierType type, + string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + var identifier = UserIdentifier.Create( + id: null, + tenant: tenant, + userKey: user.UserKey, + type: type, + value: value, + normalizedValue: value.ToLowerInvariant(), + now: user.CreatedAt, + isPrimary: true); + + await store.AddAsync(identifier); + } + + public ValueTask DisposeAsync() + { + _stores.Clear(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs new file mode 100644 index 00000000..b26395b7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/Query/UserSummaryQueryStoreContractTests.cs @@ -0,0 +1,501 @@ + +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public abstract class UserSummaryQueryStoreContractTests +{ + protected abstract Task + CreateDatabaseAsync(); + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task QueryAsync_WhenEmpty_ReturnsEmptyPage() + { + await using var db = await CreateDatabaseAsync(); + + var store = db.CreateStore(TenantA); + + var result = await store.QueryAsync(new UserQuery()); + + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(0); + result.HasNext.Should().BeFalse(); + } + + [Fact] + public async Task QueryAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var sharedKey = UserKey.New(); + + await db.SeedAsync(TenantA, + [ + CreateUser(sharedKey, "Tenant A User") + ]); + + await db.SeedAsync(TenantB, + [ + CreateUser(sharedKey, "Tenant B User") + ]); + + var resultA = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery()); + + var resultB = await db.CreateStore(TenantB) + .QueryAsync(new UserQuery()); + + resultA.TotalCount.Should().Be(1); + resultB.TotalCount.Should().Be(1); + + resultA.Items.Single().DisplayName + .Should().Be("Tenant A User"); + + resultB.Items.Single().DisplayName + .Should().Be("Tenant B User"); + } + + [Fact] + public async Task QueryAsync_With1501Users_ReturnsCorrectLastPage() + { + await using var db = await CreateDatabaseAsync(); + + var users = Enumerable.Range(1, 1501) + .Select(i => CreateUser( + UserKey.New(), + $"User {i:D4}", + Now.AddSeconds(i))) + .ToArray(); + + await db.SeedAsync(TenantA, users); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + PageNumber = 7, + PageSize = 250 + }); + + result.TotalCount.Should().Be(1501); + result.Items.Should().ContainSingle(); + result.Items[0].UserKey.Should().Be(users[1500].UserKey); + result.HasNext.Should().BeFalse(); + } + + [Fact] + public async Task QueryAsync_SearchesBeyondFirst1000Users() + { + await using var db = await CreateDatabaseAsync(); + + var users = Enumerable.Range(1, 1501) + .Select(i => CreateUser( + UserKey.New(), + $"User {i:D4}", + Now.AddSeconds(i)) with + { + Email = i == 1501 + ? "target@example.com" + : $"user{i}@example.com" + }) + .ToArray(); + + await db.SeedAsync(TenantA, users); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Search = "target@example.com", + PageSize = 50 + }); + + result.TotalCount.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].UserKey.Should().Be(users[1500].UserKey); + } + + + [Theory] + [InlineData("display")] + [InlineData("USERNAME")] + [InlineData("EXAMPLE.COM")] + [InlineData("555123")] + public async Task QueryAsync_SearchesSupportedFields( + string search) + { + await using var db = await CreateDatabaseAsync(); + + var target = new UserSummarySeed + { + UserKey = UserKey.New(), + CreatedAt = Now, + Status = UserStatus.Active, + DisplayName = "Target Display", + UserName = "targetusername", + Email = "target@example.com", + Phone = "5551234567" + }; + + await db.SeedAsync(TenantA, [target]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery { Search = search }); + + result.TotalCount.Should().Be(1); + result.Items.Should().ContainSingle(); + result.Items[0].UserKey.Should().Be(target.UserKey); + } + + [Fact] + public async Task QueryAsync_SearchWithNoMatches_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + + await db.SeedAsync(TenantA, + [ + CreateUser(UserKey.New(), "Alice"), + CreateUser(UserKey.New(), "Bob") + ]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Search = "does-not-exist" + }); + + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task QueryAsync_EmptySearchDoesNotFilter( + string? search) + { + await using var db = await CreateDatabaseAsync(); + + await db.SeedAsync(TenantA, + [ + CreateUser(UserKey.New(), "Alice"), + CreateUser(UserKey.New(), "Bob") + ]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery { Search = search }); + + result.TotalCount.Should().Be(2); + } + + + [Fact] + public async Task QueryAsync_PaginationHasNoMissingOrDuplicateUsers() + { + await using var db = await CreateDatabaseAsync(); + + var users = Enumerable.Range(1, 105) + .Select(i => CreateUser( + UserKey.New(), + $"User {i:D3}", + Now)) + .ToArray(); + + await db.SeedAsync(TenantA, users); + + var store = db.CreateStore(TenantA); + var collected = new List(); + + for (var page = 1; page <= 11; page++) + { + var result = await store.QueryAsync(new UserQuery + { + PageNumber = page, + PageSize = 10 + }); + + result.TotalCount.Should().Be(105); + collected.AddRange(result.Items.Select(x => x.UserKey)); + } + + collected.Should().HaveCount(105); + collected.Distinct().Should().HaveCount(105); + + collected.Should().BeEquivalentTo( + users.Select(x => x.UserKey)); + } + + [Fact] + public async Task QueryAsync_PageBeyondLast_ReturnsEmptyButCorrectTotal() + { + await using var db = await CreateDatabaseAsync(); + + var users = Enumerable.Range(1, 15) + .Select(i => CreateUser( + UserKey.New(), $"User {i}")) + .ToArray(); + + await db.SeedAsync(TenantA, users); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + PageNumber = 10, + PageSize = 10 + }); + + result.TotalCount.Should().Be(15); + result.Items.Should().BeEmpty(); + result.HasNext.Should().BeFalse(); + } + + [Fact] + public async Task QueryAsync_ExtremePageNumber_DoesNotOverflow() + { + await using var db = await CreateDatabaseAsync(); + + await db.SeedAsync(TenantA, + [ + CreateUser(UserKey.New(), "Alice") + ]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + PageNumber = int.MaxValue, + PageSize = 1000 + }); + + result.Items.Should().BeEmpty(); + result.TotalCount.Should().Be(1); + result.HasNext.Should().BeFalse(); + } + + + [Fact] + public async Task QueryAsync_FiltersByStatus() + { + await using var db = await CreateDatabaseAsync(); + + var active = CreateUser( + UserKey.New(), "Active User"); + + var other = CreateUser( + UserKey.New(), "Other User") with + { + Status = UserStatus.Suspended + }; + + await db.SeedAsync(TenantA, [active, other]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Status = UserStatus.Active + }); + + result.TotalCount.Should().Be(1); + result.Items.Single().UserKey.Should().Be(active.UserKey); + } + + [Fact] + public async Task QueryAsync_ExcludesDeletedUsersByDefault() + { + await using var db = await CreateDatabaseAsync(); + + var active = CreateUser( + UserKey.New(), "Active"); + + var deleted = CreateUser( + UserKey.New(), "Deleted") with + { + IsDeleted = true + }; + + await db.SeedAsync(TenantA, [active, deleted]); + + var store = db.CreateStore(TenantA); + + var normal = await store.QueryAsync(new UserQuery()); + var includingDeleted = await store.QueryAsync( + new UserQuery { IncludeDeleted = true }); + + normal.TotalCount.Should().Be(1); + normal.Items.Single().UserKey.Should().Be(active.UserKey); + + includingDeleted.TotalCount.Should().Be(2); + } + + [Fact] + public async Task QueryAsync_UserWithoutProfileIsStillReturned() + { + await using var db = await CreateDatabaseAsync(); + + var user = CreateUser( + UserKey.New(), "Not Persisted") with + { + HasProfile = false + }; + + await db.SeedAsync(TenantA, [user]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery()); + + result.Items.Should().ContainSingle(); + result.Items[0].UserKey.Should().Be(user.UserKey); + result.Items[0].DisplayName.Should().BeNull(); + } + + + [Fact] + public async Task QueryAsync_UsesRequestedProfileKey() + { + await using var db = await CreateDatabaseAsync(); + + var userKey = UserKey.New(); + var customProfile = ProfileKey.Parse("business", null); + + await db.SeedAsync(TenantA, + [ + CreateUser(userKey, "Business Profile") with + { + ProfileKey = customProfile + } + ]); + + var store = db.CreateStore(TenantA); + + var defaultResult = await store.QueryAsync( + new UserQuery()); + + var businessResult = await store.QueryAsync( + new UserQuery + { + ProfileKey = customProfile + }); + + defaultResult.TotalCount.Should().Be(1); + defaultResult.Items.Single().DisplayName.Should().BeNull(); + + businessResult.TotalCount.Should().Be(1); + businessResult.Items.Single().DisplayName + .Should().Be("Business Profile"); + } + + [Theory] + [InlineData("%")] + [InlineData("_")] + public async Task QueryAsync_SearchTreatsSqlWildcardsLiterally( + string search) + { + await using var db = await CreateDatabaseAsync(); + + var target = CreateUser( + UserKey.New(), $"Name{search}Value"); + + var other = CreateUser( + UserKey.New(), "NameWithoutWildcard"); + + await db.SeedAsync(TenantA, [target, other]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Search = search + }); + + result.TotalCount.Should().Be(1); + result.Items.Single().UserKey.Should().Be(target.UserKey); + } + + [Fact] + public async Task QueryAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var store = db.CreateStore(TenantA); + + var act = () => store.QueryAsync( + new UserQuery(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + + [Fact] + public async Task QueryAsync_SearchesByExactUserKey() + { + await using var db = await CreateDatabaseAsync(); + + var key = UserKey.FromString("specific-user-key-12345"); + + await db.SeedAsync(TenantA, + [ + CreateUser(key, "Target User"), + CreateUser(UserKey.New(), "Other User") + ]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Search = key.Value + }); + + result.TotalCount.Should().Be(1); + result.Items.Single().UserKey.Should().Be(key); + } + + [Fact] + public async Task QueryAsync_DoesNotMatchPartialUserKey() + { + await using var db = await CreateDatabaseAsync(); + + var key = UserKey.FromString("specific-user-key-12345"); + + await db.SeedAsync(TenantA, + [ + CreateUser(key, "Target User") + ]); + + var result = await db.CreateStore(TenantA) + .QueryAsync(new UserQuery + { + Search = "user-key-12345" + }); + + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + + private static UserSummarySeed CreateUser( + UserKey key, + string displayName, + DateTimeOffset? createdAt = null) + { + return new UserSummarySeed + { + UserKey = key, + DisplayName = displayName, + CreatedAt = createdAt ?? Now + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index e77fd1e3..340e9dec 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1554,6 +1554,217 @@ await f.Sut.DeleteUserAsync( Times.Exactly(2)); } + + [Fact] + public async Task GetIdentifierAsync_WhenValueInvalid_ReturnsNullWithoutStoreAccess() + { + var f = CreateFixture(); + var context = CreateContext(); + + f.IdentifierNormalizer + .Setup(x => x.Normalize(UserIdentifierType.Email, "invalid")) + .Returns(new NormalizedIdentifier( + "invalid", "", false, "identifier_invalid")); + + var result = await f.Sut.GetIdentifierAsync( + context, UserIdentifierType.Email, "invalid"); + + result.Should().BeNull(); + + f.IdentifierStore.Verify( + x => x.GetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task GetIdentifierAsync_WhenNotFound_ReturnsNull() + { + var f = CreateFixture(); + var context = CreateContext(); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Email, "ALICE@EXAMPLE.COM")) + .Returns(new NormalizedIdentifier( + "ALICE@EXAMPLE.COM", "alice@example.com", true, null)); + + f.IdentifierStore + .Setup(x => x.GetAsync( + UserIdentifierType.Email, + "alice@example.com", + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var result = await f.Sut.GetIdentifierAsync( + context, UserIdentifierType.Email, "ALICE@EXAMPLE.COM"); + + result.Should().BeNull(); + + f.IdentifierStore.Verify(x => x.GetAsync( + UserIdentifierType.Email, + "alice@example.com", + It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetIdentifierAsync_WhenFound_ReturnsMappedIdentifier() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + isPrimary: true, + isVerified: true); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Email, "alice@example.com")) + .Returns(new NormalizedIdentifier( + "alice@example.com", "alice@example.com", true, null)); + + f.IdentifierStore + .Setup(x => x.GetAsync( + UserIdentifierType.Email, + "alice@example.com", + It.IsAny())) + .ReturnsAsync(identifier); + + var result = await f.Sut.GetIdentifierAsync( + context, UserIdentifierType.Email, "alice@example.com"); + + result.Should().NotBeNull(); + result!.Id.Should().Be(identifier.Id); + result.Type.Should().Be(identifier.Type); + result.Value.Should().Be(identifier.Value); + result.NormalizedValue.Should().Be(identifier.NormalizedValue); + } + + [Fact] + public async Task UserIdentifierExistsAsync_WhenValueInvalid_ReturnsFalseWithoutStoreAccess() + { + var f = CreateFixture(); + var context = CreateContext(); + + f.IdentifierNormalizer + .Setup(x => x.Normalize(UserIdentifierType.Email, "invalid")) + .Returns(new NormalizedIdentifier( + "invalid", "", false, "identifier_invalid")); + + var result = await f.Sut.UserIdentifierExistsAsync( + context, UserIdentifierType.Email, "invalid"); + + result.Should().BeFalse(); + + f.IdentifierStore.Verify( + x => x.ExistsAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(IdentifierExistenceScope.TenantPrimaryOnly)] + [InlineData(IdentifierExistenceScope.TenantAny)] + public async Task UserIdentifierExistsAsync_TenantScope_DoesNotPassUserKey( + IdentifierExistenceScope scope) + { + var f = CreateFixture(); + var context = CreateContext(); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Email, "ALICE@EXAMPLE.COM")) + .Returns(new NormalizedIdentifier( + "ALICE@EXAMPLE.COM", "alice@example.com", true, null)); + + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Email && + q.NormalizedValue == "alice@example.com" && + q.Scope == scope && + q.UserKey == null), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult(Exists: true)); + + var result = await f.Sut.UserIdentifierExistsAsync( + context, + UserIdentifierType.Email, + "ALICE@EXAMPLE.COM", + scope); + + result.Should().BeTrue(); + + f.IdentifierStore.Verify(x => x.ExistsAsync( + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserIdentifierExistsAsync_WithinUser_PassesTargetUserKey() + { + var f = CreateFixture(); + var target = UserKey.New(); + var context = CreateContext(targetUserKey: target); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Username, "Alice")) + .Returns(new NormalizedIdentifier( + "Alice", "alice", true, null)); + + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Username && + q.NormalizedValue == "alice" && + q.Scope == IdentifierExistenceScope.WithinUser && + q.UserKey == target), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult(Exists: true)); + + var result = await f.Sut.UserIdentifierExistsAsync( + context, + UserIdentifierType.Username, + "Alice", + IdentifierExistenceScope.WithinUser); + + result.Should().BeTrue(); + } + + [Fact] + public async Task UserIdentifierExistsAsync_WhenStoreReportsMissing_ReturnsFalse() + { + var f = CreateFixture(); + var context = CreateContext(); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Username, "missing")) + .Returns(new NormalizedIdentifier( + "missing", "missing", true, null)); + + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Username && + q.NormalizedValue == "missing" && + q.Scope == IdentifierExistenceScope.TenantPrimaryOnly && + q.UserKey == null), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult(Exists: false)); + + var result = await f.Sut.UserIdentifierExistsAsync( + context, UserIdentifierType.Username, "missing"); + + result.Should().BeFalse(); + } + // ============================================================ // Helpers // ============================================================ @@ -1574,6 +1785,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat var identifierAvailability = new Mock(MockBehavior.Strict); var identifierVerifier = new Mock(MockBehavior.Strict); var sessionFactory = new Mock(MockBehavior.Strict); + var queryStoreFactory = new Mock(MockBehavior.Strict); var sessionStore = new Mock(MockBehavior.Strict); var clock = new Mock(MockBehavior.Strict); @@ -1615,6 +1827,23 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat .Returns, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + atomic .Setup(x => x.ExecuteAsync( It.IsAny>>(), @@ -1638,6 +1867,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat identifierAvailability.Object, sessionFactory.Object, identifierVerifier.Object, + queryStoreFactory.Object, options, clock.Object); From 8720d203c001633020ba0a15be5c0effb315743d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 00:23:50 +0300 Subject: [PATCH 12/26] refactor(auth): defer unimplemented authentication flows --- .../Contracts/Login/ExternalLoginRequest.cs | 16 ++++---- .../Contracts/Mfa/BeginMfaRequest.cs | 10 ++--- .../Contracts/Mfa/CompleteMfaRequest.cs | 12 +++--- .../Contracts/Mfa/MfaChallengeResult.cs | 12 +++--- .../Abstractions/IUAuthFlowService.cs | 10 +++-- .../Services/UAuthFlowService.cs | 38 +++++++++---------- .../Dtos/MfaMethod.cs | 16 ++++---- .../Dtos/UserMfaStatusInfo.cs | 14 +++---- .../Responses/BeginMfaSetupResult.cs | 14 +++---- 9 files changed, 72 insertions(+), 70 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs index 64f2be08..6fa7a402 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -1,10 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ExternalLoginRequest -{ - public required string Provider { get; init; } - public required string ExternalToken { get; init; } - public required DeviceContext Device { get; init; } -} +//public sealed record ExternalLoginRequest +//{ +// public required string Provider { get; init; } +// public required string ExternalToken { get; init; } +// public required DeviceContext Device { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs index 536ed568..7ad8bab9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -1,6 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record BeginMfaRequest -{ - public required string MfaToken { get; init; } -} +//public sealed record BeginMfaRequest +//{ +// public required string MfaToken { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs index 7aa5d7f0..b99132d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -1,7 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record CompleteMfaRequest -{ - public required string ChallengeId { get; init; } - public required string Code { get; init; } -} +//public sealed record CompleteMfaRequest +//{ +// public required string ChallengeId { get; init; } +// public required string Code { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs index c0a4aad6..9cd945ee 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -1,7 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record MfaChallengeResult -{ - public required string ChallengeId { get; init; } - public required string Method { get; init; } // totp, sms, email etc. -} +//public sealed record MfaChallengeResult +//{ +// public required string ChallengeId { get; init; } +// public required string Method { get; init; } // totp, sms, email etc. +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs index 4445c7e1..7745ccb6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs @@ -4,6 +4,8 @@ namespace CodeBeam.UltimateAuth.Server.Services; +// TODO: Implement external login, MFA, and reauthentication flows as commented out in the interface below. + /// /// Handles authentication flows such as login, /// logout, session refresh and reauthentication. @@ -14,17 +16,17 @@ public interface IUAuthFlowService Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); - Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); + //Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); - Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); + //Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); - Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); + //Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); Task LogoutAsync(LogoutRequest request, CancellationToken ct = default); Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); + //Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); } internal interface IUAuthInternalFlowService diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 85011c70..df1d2e57 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -113,23 +113,23 @@ public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct } } - public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } + //public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs index 1c86080e..9b4e7f4d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -1,9 +1,9 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +//namespace CodeBeam.UltimateAuth.Users.Contracts; -public enum MfaMethod -{ - Totp = 0, - Sms = 10, - Email = 20, - Passkey = 30 -} +//public enum MfaMethod +//{ +// Totp = 0, +// Sms = 10, +// Email = 20, +// Passkey = 30 +//} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs index ae13171f..6bdc00b6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs @@ -1,8 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +//namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed record UserMfaStatusInfo -{ - public bool IsEnabled { get; init; } - public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); - public MfaMethod? DefaultMethod { get; init; } -} +//public sealed record UserMfaStatusInfo +//{ +// public bool IsEnabled { get; init; } +// public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); +// public MfaMethod? DefaultMethod { get; init; } +//} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs index 7fb41c67..85bee388 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs @@ -1,8 +1,8 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; +//namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed record BeginMfaSetupResult -{ - public MfaMethod Method { get; init; } - public string? SharedSecret { get; init; } // TOTP - public string? QrCodeUri { get; init; } // TOTP -} +//public sealed record BeginMfaSetupResult +//{ +// public MfaMethod Method { get; init; } +// public string? SharedSecret { get; init; } // TOTP +// public string? QrCodeUri { get; init; } // TOTP +//} From ce7eda73048bedf98d67a4feb6bb2476e806be2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 13:32:29 +0300 Subject: [PATCH 13/26] refactor(tokens): separate refresh token issuance from transport contracts --- .../Contracts/Login/ReauthRequest.cs | 14 +++--- .../Contracts/Login/ReauthResult.cs | 10 ++-- .../Contracts/Token/RefreshTokenInfo.cs | 16 +++---- .../Token/RefreshTokenIssuanceResult.cs | 30 ++++++++++++ .../Abstractions/ITokenIssuer.cs | 3 +- .../Flows/Login/LoginOrchestrator.cs | 8 +++- .../Credentials/CredentialResponseWriter.cs | 2 +- .../Issuers/UAuthTokenIssuer.cs | 5 +- .../Infrastructure/RefreshTokenMapper.cs | 14 ++++++ .../Services/RefreshTokenRotationService.cs | 8 +++- .../Server/RefreshFlowServiceTests.cs | 4 +- .../RefreshTokenRotationServiceTests.cs | 47 ++++++++++++------- 12 files changed, 113 insertions(+), 48 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs index 9ffe9ab8..d8fae096 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -1,9 +1,9 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ReauthRequest -{ - public AuthSessionId SessionId { get; init; } - public required string Secret { get; init; } -} +//public sealed record ReauthRequest +//{ +// public AuthSessionId SessionId { get; init; } +// public required string Secret { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs index a047ff1d..45df39b7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -1,6 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ReauthResult -{ - public bool Success { get; init; } -} +//public sealed record ReauthResult +//{ +// public bool Success { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs index 6d5648b5..7e389170 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -1,22 +1,22 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; /// -/// Transport model for refresh token. Returned to client once upon creation. +/// Represents refresh token information intended for client-facing authentication results. /// +/// +/// Contains only the raw refresh token and its expiration time. +/// Server-side security and persistence details, such as the token hash are intentionally excluded. +/// Actual token delivery is governed by the configured response policy. +/// public sealed class RefreshTokenInfo { /// - /// Plain refresh token value (returned to client once). + /// Gets the raw refresh token to be delivered to the client through the configured delivery mechanism. /// public required string Token { get; init; } /// - /// Hash of the refresh token to be persisted. - /// - public required string TokenHash { get; init; } - - /// - /// Expiration time. + /// Gets the expiration time of the refresh token in UTC. /// public required DateTimeOffset ExpiresAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs new file mode 100644 index 00000000..b3b461e4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the result of issuing a refresh token for trusted server-side operations. +/// +/// +/// Contains both the raw refresh token and its hash. +/// The raw token is intended for one-time delivery to the client, +/// while the hash is used exclusively for server-side persistence and rotation. +/// This type is not intended to be exposed directly in client responses. +/// Use for client-facing token information. +/// +public sealed class RefreshTokenIssuanceResult +{ + /// + /// Gets the raw refresh token generated for one-time delivery to the client. + /// + public required string Token { get; init; } + + /// + /// Gets the hash of the refresh token used for server-side persistence, validation, and rotation. + /// Must never be exposed to the client. + /// + public required string TokenHash { get; init; } + + /// + /// Gets the expiration time of the refresh token in UTC. + /// + public required DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9b525f5f..46b3f351 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; namespace CodeBeam.UltimateAuth.Server.Abstactions; @@ -10,5 +11,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions; public interface ITokenIssuer { Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index dd3a4198..76179bf9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -266,10 +266,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Claims = claims.AsDictionary() }; + var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); + + var refreshIssuance = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); + tokens = new AuthTokens { - AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), - RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) + AccessToken = accessToken, + RefreshToken = refreshIssuance is null ? null : new RefreshTokenInfo { Token = refreshIssuance.Token, ExpiresAt = refreshIssuance.ExpiresAt } }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 9af510d5..ae22cc9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -57,7 +57,7 @@ public void WriteInternal(HttpContext context, GrantKind kind, string value) case TokenResponseMode.Body: // TODO: Implement body writing if needed - break; + throw new NotSupportedException("Body token delivery is not supported."); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index c4d139fb..ddada82a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure; @@ -50,7 +51,7 @@ UAuthMode.SemiHybrid or }; } - public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; @@ -84,7 +85,7 @@ await store.ExecuteAsync(async transactionCt => }, ct); } - return new RefreshTokenInfo + return new RefreshTokenIssuanceResult { Token = raw, TokenHash = hash, diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs new file mode 100644 index 00000000..8b23a309 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Contracts; + +internal static class RefreshTokenMapper +{ + public static RefreshTokenInfo ToTransport(RefreshTokenIssuanceResult result) + { + return new RefreshTokenInfo + { + Token = result.Token, + ExpiresAt = result.ExpiresAt + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 75f77923..de102bbe 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -125,6 +125,11 @@ public async Task RotateAsync(AuthFlowContext flo // Only the winning request needs an access token. var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + var refreshTokenInfo = new RefreshTokenInfo + { + Token = refreshToken.Token, + ExpiresAt = refreshToken.ExpiresAt + }; return new RefreshTokenRotationExecution { @@ -132,8 +137,7 @@ public async Task RotateAsync(AuthFlowContext flo UserKey = userKey, SessionId = sessionId, ChainId = validation.ChainId, - - Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) + Result = RefreshTokenRotationResult.Success(accessToken, refreshTokenInfo) }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index 819c6be9..df376009 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -464,7 +464,7 @@ private static RefreshTokenRotationExecution CreateSuccessfulRotation() ExpiresAt = Now.AddMinutes(15) }; - var refresh = new RefreshTokenInfo + var refresh = new RefreshTokenIssuanceResult { Token = "new-refresh-token", TokenHash = "new-refresh-token-hash", @@ -477,7 +477,7 @@ private static RefreshTokenRotationExecution CreateSuccessfulRotation() UserKey = UserKey.New(), SessionId = SessionId, ChainId = SessionChainId.New(), - Result = RefreshTokenRotationResult.Success(access, refresh) + Result = RefreshTokenRotationResult.Success(access, RefreshTokenMapper.ToTransport(refresh)) }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index e4793a89..07b62535 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -13,6 +13,7 @@ using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using Moq; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -270,7 +271,7 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou It.IsAny(), RefreshTokenPersistence.DoNotPersist, It.IsAny())) - .ReturnsAsync((RefreshTokenInfo?)null); + .ReturnsAsync((RefreshTokenIssuanceResult?)null); var sut = new RefreshTokenRotationService( validator.Object, @@ -316,7 +317,7 @@ public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() var issuer = new Mock(); var accessToken = CreateAccessToken(); - var refreshToken = CreateRefreshTokenInfo(); + var refreshToken = CreateRefreshTokenIssuanceResult(); issuer .Setup(x => x.IssueAccessTokenAsync( @@ -333,22 +334,16 @@ public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() It.IsAny())) .ReturnsAsync(refreshToken); - var sut = new RefreshTokenRotationService( - validator.Object, - storeFactory.Object, - issuer.Object); + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); - var result = await sut.RotateAsync( - CreateFlow(tenant, multiTenant: true), - CreateContext(validation.SessionId)); + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); result.Result.IsSuccess.Should().BeTrue(); - - result.Result.AccessToken - .Should().BeSameAs(accessToken); - - result.Result.RefreshToken - .Should().BeSameAs(refreshToken); + result.Result.AccessToken.Should().BeSameAs(accessToken); + result.Result.RefreshToken.Should().NotBeNull(); + result.Result.RefreshToken!.Token.Should().Be(refreshToken.Token); + result.Result.RefreshToken.ExpiresAt.Should().Be(refreshToken.ExpiresAt); + typeof(RefreshTokenInfo).GetProperty(nameof(RefreshTokenIssuanceResult.TokenHash)).Should().BeNull(); result.Tenant.Should().Be(tenant); result.UserKey.Should().Be(validation.UserKey); @@ -477,7 +472,7 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto var issuer = new Mock(); var accessToken = CreateAccessToken(); - var refreshToken = CreateRefreshTokenInfo(); + var refreshToken = CreateRefreshTokenIssuanceResult(); issuer .Setup(x => x.IssueAccessTokenAsync( @@ -533,6 +528,22 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto Times.Never); } + [Fact] + public void RefreshTokenInfo_DoesNotExposeTokenHash() + { + var info = new RefreshTokenInfo + { + Token = "raw-refresh-token", + ExpiresAt = DateTimeOffset.UtcNow.AddDays(7) + }; + + var json = JsonSerializer.Serialize(info); + + json.Should().Contain("raw-refresh-token"); + json.Should().NotContain("TokenHash"); + typeof(RefreshTokenInfo).GetProperty("TokenHash").Should().BeNull(); + } + private static Mock CreateValidator(RefreshTokenValidationResult result) { @@ -620,7 +631,7 @@ private static AccessToken CreateAccessToken() ExpiresAt = Now.AddMinutes(15) }; - private static RefreshTokenInfo CreateRefreshTokenInfo() + private static RefreshTokenIssuanceResult CreateRefreshTokenIssuanceResult() => new() { Token = "new-refresh-token", @@ -646,7 +657,7 @@ private static void SetupSuccessfulIssuer( It.IsAny(), RefreshTokenPersistence.DoNotPersist, It.IsAny())) - .ReturnsAsync(CreateRefreshTokenInfo()); + .ReturnsAsync(CreateRefreshTokenIssuanceResult()); } private static AuthFlowContext CreateFlow(TenantKey? tenant = null, bool multiTenant = false) From d01312040eae7713d6dd10a0ff5703ba84b55313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 14:29:18 +0300 Subject: [PATCH 14/26] test(core): cover JSON converters and typed identifier serialization --- .../Contracts/Session/AuthValidationResult.cs | 6 +- .../Session/SessionValidationResult.cs | 4 +- .../Token/RefreshTokenRotationResult.cs | 2 - .../Domain/Security/PasswordHash.cs | 4 + .../Domain/Session/AuthSessionId.cs | 14 +- .../Domain/User/UserKey.cs | 8 +- .../Converters/DeviceIdJsonConverter.cs | 3 + .../Converters/SessionRootIdJsonConverter.cs | 4 +- .../Converters/UserKeyJsonConverter.cs | 7 +- .../Infrastructure/SessionValidationMapper.cs | 81 +--- .../MultiTenancy/TenantKey.cs | 8 +- .../Endpoints/ValidateEndpointHandler.cs | 24 +- .../Issuers/UAuthTokenIssuer.cs | 6 +- .../RemoteSessionValidatorTests.cs | 16 +- .../Server/SessionTouchServiceTests.cs | 2 +- .../Server/ValidateEndpointHandlerTests.cs | 8 +- .../Utility/JsonConverterTests.cs | 416 ++++++++++++++++++ 17 files changed, 489 insertions(+), 124 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Utility/JsonConverterTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index bc4d9c9e..ec6a0c54 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -9,11 +9,11 @@ public sealed record AuthValidationResult { public required SessionState State { get; init; } public AuthStateSnapshot? Snapshot { get; init; } - public Guid? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } - public Guid? RootId { get; init; } + public SessionRootId? RootId { get; init; } - public string? BoundDeviceId { get; init; } + public DeviceId? BoundDeviceId { get; init; } public bool IsValid => State == SessionState.Active; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs index 9443abab..2ff5672f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -51,7 +51,7 @@ public static SessionValidationResult Active( public static SessionValidationResult Invalid( SessionState state, - UserKey? userId = null, + UserKey? userKey = null, AuthSessionId? sessionId = null, SessionChainId? chainId = null, SessionRootId? rootId = null, @@ -59,7 +59,7 @@ public static SessionValidationResult Invalid( => new() { State = state, - UserKey = userId, + UserKey = userKey, SessionId = sessionId, ChainId = chainId, RootId = rootId, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index af715f12..1e190647 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -6,9 +6,7 @@ public sealed record RefreshTokenRotationResult { public bool IsSuccess { get; init; } public bool ReauthRequired { get; init; } - public bool IsReuseDetected { get; init; } // internal use - public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } public RefreshTokenInfo? RefreshToken { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs index a95107d6..9fac6f84 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs @@ -1,6 +1,10 @@ using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; namespace CodeBeam.UltimateAuth.Core; + +[JsonConverter(typeof(PasswordHashJsonConverter))] public readonly record struct PasswordHash : IParsable { public string Algorithm { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 4bc6988f..7d28fd67 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -14,17 +14,7 @@ private AuthSessionId(string value) Value = value; } - public static bool TryCreate(string? raw, out AuthSessionId id) - { - if (IsValid(raw)) - { - id = new AuthSessionId(raw!); - return true; - } - - id = default; - return false; - } + public static bool TryCreate(string? raw, out AuthSessionId id) => TryParse(raw, null, out id); public static AuthSessionId Parse(string s, IFormatProvider? provider) { @@ -34,6 +24,8 @@ public static AuthSessionId Parse(string s, IFormatProvider? provider) throw new FormatException("Invalid AuthSessionId."); } + public static AuthSessionId Parse(string value) => Parse(value, null); + public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result) { if (IsValid(s)) diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs index e45d5220..83110647 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -43,12 +43,6 @@ public static bool TryParse(string? s, IFormatProvider? provider, out UserKey re return false; } - if (Guid.TryParse(s, out var guid)) - { - result = FromGuid(guid); - return true; - } - result = FromString(s); return true; } @@ -61,6 +55,8 @@ public static UserKey Parse(string s, IFormatProvider? provider) return result; } + public static UserKey Parse(string value) => Parse(value, null); + public override string ToString() => Value; public static implicit operator string(UserKey key) => key.Value; diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs index 95299f6c..37b72535 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs @@ -8,6 +8,9 @@ public sealed class DeviceIdJsonConverter : JsonConverter { public override DeviceId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("DeviceId must be a string."); + var value = reader.GetString(); if (!DeviceId.TryCreate(value, out var id)) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs index 24c3c08f..53864272 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs @@ -9,12 +9,12 @@ public sealed class SessionRootIdJsonConverter : JsonConverter public override SessionRootId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.String) - throw new JsonException("SessionChainId must be a string."); + throw new JsonException("SessionRootId must be a string."); var raw = reader.GetString(); if (!SessionRootId.TryCreate(raw!, out var id)) - throw new JsonException($"Invalid SessionChainId value: '{raw}'"); + throw new JsonException($"Invalid SessionRootId value: '{raw}'"); return id; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs index 0a4da433..e7bb5742 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs @@ -14,7 +14,12 @@ public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, Json if (reader.TokenType != JsonTokenType.String) throw new JsonException("UserKey must be a string."); - return UserKey.FromString(reader.GetString()!); + var value = reader.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + throw new JsonException("UserKey cannot be null or empty."); + + return UserKey.FromString(value); } public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index bb4458f2..d935f685 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -14,52 +14,30 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes return SessionValidationResult.Invalid( dto.State, sessionId: sessionId, - chainId: TryParseChainId(dto.ChainId), - rootId: TryParseRootId(dto.RootId), - boundDeviceId: TryParseDeviceId(dto.BoundDeviceId)); + chainId: dto.ChainId, + rootId: dto.RootId, + boundDeviceId: dto.BoundDeviceId); } - // - // Active is a stronger contract than merely receiving - // a successful HTTP response. All required security - // lineage and identity data must be present. - // + // An active validation result must contain + // the required identity and security lineage. if (dto.Snapshot?.Identity is null) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId); } - if (dto.ChainId is not Guid chainGuid || - chainGuid == Guid.Empty) + if (dto.ChainId is not SessionChainId chainId || chainId.IsUnassigned) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId); } - var chainId = - SessionChainId.From(chainGuid); - - if (dto.RootId is not Guid rootGuid || - rootGuid == Guid.Empty) + if (dto.RootId is not SessionRootId rootId || rootId.Value == Guid.Empty) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId, - chainId: chainId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId, chainId: chainId); } - var rootId = - SessionRootId.From(rootGuid); - - var identity = - dto.Snapshot.Identity; - - var boundDeviceId = - TryParseDeviceId(dto.BoundDeviceId); + var identity = dto.Snapshot.Identity; return SessionValidationResult.Active( tenant: identity.Tenant, @@ -68,10 +46,8 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes chainId: chainId, rootId: rootId, claims: dto.Snapshot.Claims, - authenticatedAt: - identity.AuthenticatedAt - ?? DateTimeOffset.UtcNow, - boundDeviceId: boundDeviceId); + authenticatedAt: identity.AuthenticatedAt ?? DateTimeOffset.UtcNow, + boundDeviceId: dto.BoundDeviceId); } public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) @@ -88,35 +64,4 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes BoundDeviceId = result.BoundDeviceId }; } - - private static SessionChainId? TryParseChainId(Guid? value) - { - if (value is not Guid guid || - guid == Guid.Empty) - { - return null; - } - - return SessionChainId.From(guid); - } - - private static SessionRootId? TryParseRootId(Guid? value) - { - if (value is not Guid guid || - guid == Guid.Empty) - { - return null; - } - - return SessionRootId.From(guid); - } - - private static DeviceId? TryParseDeviceId(string? value) - { - return DeviceId.TryCreate( - value, - out var id) - ? id - : null; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs index 949dc694..0b7d3838 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -38,6 +38,8 @@ public static TenantKey Parse(string s, IFormatProvider? provider) return result; } + public static TenantKey Parse(string value) => Parse(value, null); + public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result) { result = default; @@ -50,7 +52,11 @@ public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result = FromExternal(s); return true; } - catch + catch (ArgumentException) + { + return false; + } + catch (SecurityException) { return false; } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index bbe6fc31..84f22cde 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -76,9 +76,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Ok(new AuthValidationResult { State = result.State, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }); } @@ -88,9 +88,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken new AuthValidationResult { State = SessionState.Invalid, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }, statusCode: StatusCodes.Status401Unauthorized ); @@ -104,9 +104,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken new AuthValidationResult { State = SessionState.Invalid, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }, statusCode: StatusCodes.Status401Unauthorized); @@ -115,9 +115,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Ok(new AuthValidationResult { State = SessionState.Active, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value, + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId, Snapshot = snapshot }); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index ddada82a..0c2df16a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -41,7 +41,7 @@ public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuan { // TODO: Discuss, Hybrid token may be JWT. UAuthMode.PureOpaque or UAuthMode.Hybrid => - Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())), + Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)), UAuthMode.SemiHybrid or UAuthMode.PureJwt => @@ -93,7 +93,7 @@ await store.ExecuteAsync(async transactionCt => }; } - private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, AuthSessionId? sessionId) { string token = _opaqueGenerator.Generate(); @@ -102,7 +102,7 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi Token = token, Format = TokenFormat.Opaque, ExpiresAt = expires, - SessionId = sessionId + SessionId = sessionId.ToString() }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs index 8c6b835b..948cb560 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs @@ -222,8 +222,8 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidSnapshot_ShouldReturnA var dto = new AuthValidationResult { State = SessionState.Active, - ChainId = chainId.Value, - RootId = rootId.Value, + ChainId = chainId, + RootId = rootId, Snapshot = new AuthStateSnapshot { @@ -272,8 +272,8 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutIdentity_Should var dto = new AuthValidationResult { State = SessionState.Active, - ChainId = SessionChainId.New().Value, - RootId = SessionRootId.New().Value, + ChainId = SessionChainId.New(), + RootId = SessionRootId.New(), Snapshot = new AuthStateSnapshot { @@ -304,8 +304,8 @@ public async Task ValidateSessionAsync_WhenHubReturnsValidWithoutSnapshot_Should var dto = new AuthValidationResult { State = SessionState.Active, - ChainId = SessionChainId.New().Value, - RootId = SessionRootId.New().Value, + ChainId = SessionChainId.New(), + RootId = SessionRootId.New(), Snapshot = null }; @@ -346,8 +346,8 @@ public void ToDomain_WhenIsValidTrueButStateIsRevoked_ShouldFailClosed() var dto = new AuthValidationResult { State = SessionState.Revoked, - ChainId = SessionChainId.New().Value, - RootId = SessionRootId.New().Value, + ChainId = SessionChainId.New(), + RootId = SessionRootId.New(), Snapshot = new AuthStateSnapshot { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs index 73cbe389..df10514a 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs @@ -40,7 +40,7 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() var sut = new SessionTouchService(factory.Object); var validation = SessionValidationResult.Invalid( SessionState.Active, - userId: TestUsers.User, + userKey: TestUsers.User, sessionId: TestIds.Session("active-session")); var result = await sut.RefreshAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs index 2a06ca4e..675a71ec 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs @@ -225,7 +225,7 @@ public async Task ValidateAsync_WhenValidationIsInvalidWithoutUserKey_ReturnsOkI var validation = SessionValidationResult.Invalid( SessionState.Invalid, - userId: null, + userKey: null, sessionId: sessionId); fixture.SessionValidator @@ -316,10 +316,10 @@ public async Task ValidateAsync_WhenSessionIsValid_ReturnsOkActiveWithSnapshot() .BeSameAs(snapshot); value.ChainId.Should() - .Be(validation.ChainId!.Value.Value); + .Be(validation.ChainId!); value.RootId.Should() - .Be(validation.RootId!.Value.Value); + .Be(validation.RootId!); fixture.SnapshotFactory.Verify( x => x.CreateAsync( @@ -350,7 +350,7 @@ public async Task ValidateAsync_WhenValidationIsInvalidButContainsUserKey_Return var validation = SessionValidationResult.Invalid( SessionState.Revoked, - userId: userKey, + userKey: userKey, sessionId: sessionId); fixture.SessionValidator diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Utility/JsonConverterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Utility/JsonConverterTests.cs new file mode 100644 index 00000000..0187d335 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Utility/JsonConverterTests.cs @@ -0,0 +1,416 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core; + +public sealed class JsonConverterTests +{ + [Fact] + public void AuthSessionId_ShouldRoundTrip() + { + var original = AuthSessionId.Parse("auth-session-id-12345678901234567890", null); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + restored.Should().Be(original); + } + + [Fact] + public void SessionChainId_ShouldRoundTripUsingNFormat() + { + var original = SessionChainId.New(); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be($"\"{original.Value:N}\""); + restored.Should().Be(original); + } + + [Fact] + public void SessionRootId_ShouldRoundTripUsingNFormat() + { + var original = SessionRootId.New(); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be($"\"{original.Value:N}\""); + restored.Should().Be(original); + } + + [Fact] + public void TokenId_ShouldRoundTripUsingNFormat() + { + var original = TokenId.New(); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be($"\"{original.Value:N}\""); + restored.Should().Be(original); + } + + [Fact] + public void DeviceId_ShouldRoundTrip() + { + var original = DeviceId.Create( + "device-identifier-with-sufficient-length"); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be($"\"{original.Value}\""); + restored.Should().Be(original); + } + + [Fact] + public void TenantKey_ShouldRoundTrip() + { + var original = TenantKey.FromExternal("tenant-a"); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + restored.Should().Be(original); + } + + [Theory] + [InlineData("\"invalid\"")] + [InlineData("\"\"")] + [InlineData("123")] + [InlineData("null")] + public void SessionChainId_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Theory] + [InlineData("\"invalid\"")] + [InlineData("\"\"")] + [InlineData("123")] + [InlineData("null")] + public void SessionRootId_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Theory] + [InlineData("\"short\"")] + [InlineData("\"\"")] + [InlineData("\"undefined\"")] + public void DeviceId_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Fact] + public void NullableTypedIds_ShouldAcceptNull() + { + var json = """ + { + "ChainId": null, + "RootId": null, + "BoundDeviceId": null + } + """; + + var restored = JsonSerializer.Deserialize(json); + + restored.Should().NotBeNull(); + restored!.ChainId.Should().BeNull(); + restored.RootId.Should().BeNull(); + restored.BoundDeviceId.Should().BeNull(); + } + + [Fact] + public void AuthValidationResult_ShouldSerializeTypedIdsAsStrings() + { + var chainId = SessionChainId.New(); + var rootId = SessionRootId.New(); + var deviceId = DeviceId.Create("device-identifier-with-sufficient-length"); + + var result = new AuthValidationResult + { + State = SessionState.Active, + ChainId = chainId, + RootId = rootId, + BoundDeviceId = deviceId + }; + + var json = JsonSerializer.Serialize(result); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + root.GetProperty("ChainId").GetString() + .Should().Be(chainId.Value.ToString("N")); + + root.GetProperty("RootId").GetString() + .Should().Be(rootId.Value.ToString("N")); + + root.GetProperty("BoundDeviceId").GetString() + .Should().Be(deviceId.Value); + + var restored = JsonSerializer.Deserialize(json); + + restored.Should().NotBeNull(); + restored!.ChainId.Should().Be(chainId); + restored.RootId.Should().Be(rootId); + restored.BoundDeviceId.Should().Be(deviceId); + } + + [Fact] + public void PasswordHash_ShouldRoundTrip() + { + var original = PasswordHash.Create( + "argon2id", + "encoded-hash-value"); + + var json = JsonSerializer.Serialize( + original); + + json.Should().Be("\"argon2id$encoded-hash-value\""); + + var restored = JsonSerializer.Deserialize( + json); + + restored.Should().Be(original); + } + + [Fact] + public void PasswordHash_ShouldSupportLegacyFormat() + { + const string json = "\"legacy-hash-value\""; + + var restored = JsonSerializer.Deserialize( + json); + + restored.Algorithm.Should().Be("legacy"); + restored.Hash.Should().Be("legacy-hash-value"); + + JsonSerializer.Serialize(restored) + .Should().Be("\"legacy$legacy-hash-value\""); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \"")] + [InlineData("123")] + [InlineData("true")] + [InlineData("{}")] + public void PasswordHash_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize( + json); + + act.Should().Throw(); + } + + [Fact] + public void ProfileKey_ShouldRoundTripAndNormalize() + { + var original = ProfileKey.Parse(" WORK ", null); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be("\"work\""); + restored.Should().Be(original); + restored.Value.Should().Be("work"); + } + + [Fact] + public void ProfileKey_ShouldRoundTripDefault() + { + var original = ProfileKey.Default; + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be("\"default\""); + restored.Should().Be(original); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \"")] + [InlineData("123")] + [InlineData("true")] + public void ProfileKey_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Fact] + public void ProfileKey_ShouldRejectValuesLongerThan64Characters() + { + var json = JsonSerializer.Serialize(new string('a', 65)); + + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Fact] + public void ProfileKey_ShouldAcceptNullAsDefault() + { + // This behavior is explicitly supported by ProfileKeyJsonConverter. + var restored = JsonSerializer.Deserialize("null"); + + restored.Should().Be(default(ProfileKey)); + } + + [Fact] + public void DeviceContext_ShouldRoundTrip() + { + var deviceId = DeviceId.Create( + "device-identifier-with-sufficient-length"); + + var original = DeviceContext.Create( + deviceId, + deviceType: " Desktop ", + platform: " Windows ", + operatingSystem: " Windows 11 ", + browser: " Edge ", + ipAddress: " 127.0.0.1 "); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + restored.Should().NotBeNull(); + + restored!.DeviceId.Should().Be(deviceId); + restored.DeviceType.Should().Be("desktop"); + restored.Platform.Should().Be("windows"); + restored.OperatingSystem.Should().Be("windows 11"); + restored.Browser.Should().Be("edge"); + restored.IpAddress.Should().Be("127.0.0.1"); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + root.GetProperty("deviceId").GetString() + .Should().Be(deviceId.Value); + + root.GetProperty("deviceType").GetString() + .Should().Be("desktop"); + } + + [Fact] + public void DeviceContext_WithoutDeviceId_ShouldBeAnonymous() + { + const string json = """ + { + "deviceType": "desktop", + "platform": "windows" + } + """; + + var restored = JsonSerializer.Deserialize(json); + + restored.Should().NotBeNull(); + restored!.HasDeviceId.Should().BeFalse(); + + restored.DeviceId.Should().BeNull(); + restored.DeviceType.Should().BeNull(); + restored.Platform.Should().BeNull(); + } + + [Fact] + public void DeviceContext_Anonymous_ShouldRoundTrip() + { + var original = DeviceContext.Anonymous(); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + restored.Should().NotBeNull(); + restored!.HasDeviceId.Should().BeFalse(); + restored.DeviceId.Should().BeNull(); + } + + [Fact] + public void DeviceContext_ShouldRejectInvalidDeviceId() + { + const string json = """ + { + "deviceId": "short", + "deviceType": "desktop" + } + """; + + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw() + .WithMessage("*Invalid DeviceId*"); + } + + [Theory] + [InlineData("\"device\"")] + [InlineData("123")] + [InlineData("[]")] + public void DeviceContext_ShouldRejectNonObjectValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + [Fact] + public void UserKey_ShouldRoundTrip() + { + var original = UserKey.FromString("external-user-123"); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be("\"external-user-123\""); + restored.Should().Be(original); + } + + [Fact] + public void UserKey_GuidBased_ShouldRoundTrip() + { + var original = UserKey.New(); + + var json = JsonSerializer.Serialize(original); + var restored = JsonSerializer.Deserialize(json); + + json.Should().Be($"\"{original.Value}\""); + restored.Should().Be(original); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \"")] + [InlineData("123")] + [InlineData("true")] + public void UserKey_ShouldRejectInvalidValues(string json) + { + var act = () => JsonSerializer.Deserialize(json); + + act.Should().Throw(); + } + + private sealed class NullableIdsDto + { + public SessionChainId? ChainId { get; init; } + public SessionRootId? RootId { get; init; } + public DeviceId? BoundDeviceId { get; init; } + } +} From 68c73205fb07940361c6e97b76115b5dad70f5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 16:01:30 +0300 Subject: [PATCH 15/26] fix(auth): preserve multi-valued claims across token issuance and refresh --- .../Contracts/Token/TokenIssuanceContext.cs | 5 +- .../Domain/Session/ClaimsSnapshot.cs | 4 +- .../Flows/Login/LoginOrchestrator.cs | 2 +- .../Generators/JwtTokenGenerator.cs | 29 +- .../Issuers/UAuthTokenIssuer.cs | 34 +- .../Services/RefreshTokenRotationService.cs | 13 +- .../Server/JwtTokenGeneratorTests.cs | 228 ++++++++++++++ .../RefreshTokenRotationServiceTests.cs | 34 +- .../Tokens/UAuthTokenIssuerTests.cs | 294 ++++++++++++++++-- 9 files changed, 575 insertions(+), 68 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/JwtTokenGeneratorTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs index fb95004f..91f1cd5a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -6,9 +6,8 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TokenIssuanceContext { public required UserKey UserKey { get; init; } - public TenantKey Tenant { get; init; } - public IReadOnlyDictionary Claims { get; set; } = new Dictionary(); + public required TenantKey Tenant { get; init; } + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; public AuthSessionId? SessionId { get; init; } public SessionChainId? ChainId { get; init; } - public DateTimeOffset IssuedAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 99ff48b2..309f961b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -99,11 +99,11 @@ public override int GetHashCode() { int hash = 17; - foreach (var (type, values) in Claims.OrderBy(x => x.Key)) + foreach (var (type, values) in Claims.OrderBy(x => x.Key, StringComparer.Ordinal)) { hash = hash * 23 + type.GetHashCode(); - foreach (var value in values.OrderBy(v => v)) + foreach (var value in values.OrderBy(v => v, StringComparer.Ordinal)) { hash = hash * 23 + value.GetHashCode(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 76179bf9..4e412432 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -263,7 +263,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, ChainId = issuedSession.Session.ChainId, - Claims = claims.AsDictionary() + Claims = claims }; var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs index a9ab6d46..b260a9f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs @@ -31,36 +31,33 @@ public string CreateToken(UAuthJwtTokenDescriptor descriptor) Claims = BuildClaims(descriptor), - SigningCredentials = new SigningCredentials( - signingKey.Key, - signingKey.Algorithm) + SigningCredentials = new SigningCredentials(signingKey.Key, signingKey.Algorithm) }; - tokenDescriptor.AdditionalHeaderClaims = new Dictionary - { - ["kid"] = signingKey.KeyId - }; - return _handler.CreateToken(tokenDescriptor); } private static IDictionary BuildClaims(UAuthJwtTokenDescriptor descriptor) { - var claims = new Dictionary - { - ["sub"] = descriptor.Subject - }; - - claims["tenant"] = descriptor.Tenant; + var claims = new Dictionary(StringComparer.Ordinal); if (descriptor.Claims is not null) { - foreach (var kv in descriptor.Claims) + foreach (var (type, value) in descriptor.Claims) { - claims[kv.Key] = kv.Value; + // These claims are controlled by SecurityTokenDescriptor + // or explicitly defined by UAuthJwtTokenDescriptor. + if (type is "sub" or "tenant" or "iss" or "aud" or "exp" or "nbf" or "iat") + continue; + + claims[type] = value; } } + // Framework-owned identity claims always take precedence. + claims["sub"] = descriptor.Subject; + claims["tenant"] = descriptor.Tenant.Value; + return claims; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 0c2df16a..323055e6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -102,26 +102,48 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, AuthSessionId Token = token, Format = TokenFormat.Opaque, ExpiresAt = expires, - SessionId = sessionId.ToString() + SessionId = sessionId?.Value }; } private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires) { - var claims = new Dictionary(); + var claims = new Dictionary(StringComparer.Ordinal); // Custom/application claims are added first. // Framework-owned security claims below always take precedence. - foreach (var kv in context.Claims) - claims[kv.Key] = kv.Value; + foreach (var (type, values) in context.Claims.Claims) + { + if (values.Count == 0) + continue; + + // Framework - owned claims cannot be supplied by applications. + if (type is + "sub" or + "tenant" or + "sid" or + "jti" or + "iss" or + "aud" or + "exp" or + "nbf" or + "iat") + continue; + + claims[type] = values.Count == 1 + ? values.First() + : values.ToArray(); + } // UltimateAuth-owned identity/security claims must never be overridable // by caller-provided claims. claims["sub"] = context.UserKey.Value; - claims["tenant"] = context.Tenant; + claims["tenant"] = context.Tenant.Value; if (context.SessionId is AuthSessionId sessionId) - claims["sid"] = sessionId; + claims["sid"] = sessionId.Value; + else + claims.Remove("sid"); if (tokens.AddJwtIdClaim) claims["jti"] = _opaqueGenerator.GenerateJwtId(); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index de102bbe..047a43d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; @@ -13,12 +14,15 @@ public sealed class RefreshTokenRotationService : IRefreshTokenRotationService private readonly IRefreshTokenValidator _validator; private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; + private readonly IUserClaimsProvider _claimsProvider; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer) + + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IUserClaimsProvider claimsProvider) { _validator = validator; _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; + _claimsProvider = claimsProvider; } // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. @@ -71,6 +75,8 @@ public async Task RotateAsync(AuthFlowContext flo var store = _storeFactory.Create(validation.Tenant); + var claims = await _claimsProvider.GetClaimsAsync(validation.Tenant, userKey, ct); + var tokenContext = new TokenIssuanceContext { Tenant = flow.OriginalOptions.MultiTenant.Enabled @@ -79,7 +85,8 @@ public async Task RotateAsync(AuthFlowContext flo UserKey = userKey, SessionId = sessionId, - ChainId = validation.ChainId + ChainId = validation.ChainId, + Claims = claims }; // Generate candidate replacement refresh token. diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/JwtTokenGeneratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/JwtTokenGeneratorTests.cs new file mode 100644 index 00000000..0a24ed6c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/JwtTokenGeneratorTests.cs @@ -0,0 +1,228 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using FluentAssertions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class JwtTokenGeneratorTests +{ + private static readonly DateTimeOffset Now = + new(2026, 10, 10, 12, 0, 0, TimeSpan.Zero); + + private const string Subject = "test-user-123"; + private const string Issuer = "https://auth.example.test"; + private const string Audience = "ultimateauth-api"; + + private static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + private static JwtTokenGenerator CreateSut() + { + return new JwtTokenGenerator( + new DevelopmentJwtSigningKeyProvider()); + } + + private static UAuthJwtTokenDescriptor CreateDescriptor( + IReadOnlyDictionary? claims = null) + { + return new UAuthJwtTokenDescriptor + { + Subject = Subject, + Issuer = Issuer, + Audience = Audience, + IssuedAt = Now, + ExpiresAt = Now.AddMinutes(15), + Tenant = Tenant, + Claims = claims, + KeyId = "dev-uauth" + }; + } + + private static JsonWebToken CreateToken( + UAuthJwtTokenDescriptor descriptor) + { + var raw = CreateSut().CreateToken(descriptor); + + return new JsonWebTokenHandler().ReadJsonWebToken(raw); + } + + [Fact] + public void CreateToken_ShouldPreserveMultipleClaimValues() + { + var descriptor = CreateDescriptor( + new Dictionary + { + ["role"] = new[] { "Admin", "Editor" }, + ["uauth:permission"] = new[] + { + "users.read", + "users.write" + } + }); + + var token = CreateToken(descriptor); + + using var payload = JsonDocument.Parse( + Base64UrlEncoder.Decode( + token.EncodedPayload)); + + var root = payload.RootElement; + + root.GetProperty("role") + .EnumerateArray() + .Select(x => x.GetString()) + .Should() + .BeEquivalentTo("Admin", "Editor"); + + root.GetProperty("uauth:permission") + .EnumerateArray() + .Select(x => x.GetString()) + .Should() + .BeEquivalentTo("users.read", "users.write"); + } + + [Fact] + public void CreateToken_ShouldUseTrustedSubjectAndTenant() + { + var descriptor = CreateDescriptor( + new Dictionary + { + ["sub"] = "attacker-user", + ["tenant"] = "attacker-tenant", + ["department"] = "legal" + }); + + var token = CreateToken(descriptor); + + token.Subject.Should().Be(Subject); + + token.GetClaim("tenant")!.Value + .Should().Be(Tenant.Value); + + token.GetClaim("department")!.Value + .Should().Be("legal"); + } + + [Fact] + public void CreateToken_ShouldPreserveSessionIdAndJwtId() + { + var descriptor = CreateDescriptor( + new Dictionary + { + ["sid"] = "session-123", + ["jti"] = "jwt-123" + }); + + var token = CreateToken(descriptor); + + token.GetClaim("sid")!.Value + .Should().Be("session-123"); + + token.GetClaim("jti")!.Value + .Should().Be("jwt-123"); + } + + [Fact] + public void CreateToken_ShouldUseConfiguredIssuerAndAudience() + { + var token = CreateToken(CreateDescriptor()); + + token.Issuer.Should().Be(Issuer); + + token.Audiences.Should() + .ContainSingle() + .Which.Should().Be(Audience); + } + + [Fact] + public void CreateToken_ShouldUseConfiguredLifetime() + { + var token = CreateToken(CreateDescriptor()); + + token.ValidFrom.Should() + .Be(Now.UtcDateTime); + + token.ValidTo.Should() + .Be(Now.AddMinutes(15).UtcDateTime); + } + + [Fact] + public async Task CreateToken_ShouldProduceValidSignature() + { + var keyProvider = + new DevelopmentJwtSigningKeyProvider(); + + var generator = new JwtTokenGenerator(keyProvider); + + var descriptor = CreateDescriptor(); + + var raw = generator.CreateToken(descriptor); + + var signingKey = keyProvider.Resolve("dev-uauth"); + + var handler = new JsonWebTokenHandler(); + + var validation = await handler.ValidateTokenAsync( + raw, + new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = Issuer, + + ValidateAudience = true, + ValidAudience = Audience, + + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey.Key, + + RequireSignedTokens = true, + ValidateLifetime = false, + + ValidAlgorithms = new[] + { + SecurityAlgorithms.HmacSha256 + } + }); + + validation.IsValid.Should().BeTrue( + validation.Exception?.ToString()); + } + + [Fact] + public void CreateToken_ShouldIgnoreReservedJwtMetadataClaims() + { + var descriptor = CreateDescriptor( + new Dictionary + { + ["iss"] = "attacker-issuer", + ["aud"] = "attacker-audience", + ["exp"] = 1L, + ["nbf"] = 1L, + ["iat"] = 1L + }); + + var token = CreateToken(descriptor); + + token.Issuer.Should().Be(Issuer); + + token.Audiences.Should().Contain(Audience); + + token.ValidFrom.Should().Be(Now.UtcDateTime); + + token.ValidTo.Should() + .Be(Now.AddMinutes(15).UtcDateTime); + } + + [Fact] + public void CreateToken_ShouldIncludeSigningKeyIdInHeader() + { + var token = CreateToken(CreateDescriptor()); + + token.Kid.Should().Be("dev-uauth"); + token.Alg.Should().Be(SecurityAlgorithms.HmacSha256); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 07b62535..51f24ac0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -27,13 +27,14 @@ public async Task RotateAsync_WhenValidationIsInvalid_ReturnsFailedWithoutCreati var validator = new Mock(); var storeFactory = new Mock(); var issuer = new Mock(); + var claimsProvider = new Mock(); var clock = new TestClock(Now); validator .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(RefreshTokenValidationResult.Invalid()); - var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object, claimsProvider.Object); var result = await sut.RotateAsync(CreateFlow(), CreateContext()); @@ -63,11 +64,13 @@ public async Task RotateAsync_WhenTokenIsNotFound_ReturnsFailedWithoutCreatingSt var issuer = new Mock(); + var claimsProvider = new Mock(); var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow(), @@ -119,11 +122,13 @@ public async Task RotateAsync_WhenTokenIsExpired_ReturnsFailedWithoutMutatingSto var issuer = new Mock(); + var claimsProvider = new Mock(); var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), @@ -180,6 +185,7 @@ public async Task RotateAsync_WhenTokenIsAlreadyConsumed_ShouldRevokeCompromised var issuer = new Mock(); + var claimsProvider = new Mock(); validator .Setup(x => x.ValidateAsync( @@ -212,7 +218,8 @@ public async Task RotateAsync_WhenTokenIsAlreadyConsumed_ShouldRevokeCompromised new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( @@ -264,6 +271,7 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou var store = CreateExecutableStore(); var storeFactory = CreateStoreFactory(tenant, store); var issuer = new Mock(); + var claimsProvider = new Mock(); issuer .Setup(x => x.IssueRefreshTokenAsync( @@ -276,7 +284,8 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), @@ -313,6 +322,7 @@ public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() var validator = CreateValidator(validation); var store = CreateSuccessfulRotationStore(); var storeFactory = CreateStoreFactory(tenant, store); + var claimsProvider = new Mock(); var issuer = new Mock(); @@ -334,7 +344,7 @@ public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() It.IsAny())) .ReturnsAsync(refreshToken); - var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object, claimsProvider.Object); var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); @@ -389,6 +399,7 @@ public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForToken var issuer = new Mock(); + var claimsProvider = new Mock(); TokenIssuanceContext? captured = null; @@ -403,7 +414,8 @@ public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForToken new RefreshTokenRotationService( CreateValidator(validation).Object, CreateStoreFactory(tenant, store).Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow( @@ -427,6 +439,7 @@ public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIs var issuer = new Mock(); + var claimsProvider = new Mock(); TokenIssuanceContext? captured = null; @@ -443,7 +456,8 @@ public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIs CreateStoreFactory( TenantKey.Single, store).Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow( @@ -468,6 +482,7 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto var validator = CreateValidator(validation); var store = CreateExecutableStore(); var storeFactory = CreateStoreFactory(tenant, store); + var claimsProvider = new Mock(); var issuer = new Mock(); @@ -500,7 +515,8 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs index e8316942..0aa2f3ae 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -152,11 +152,10 @@ public async Task IssueAccessTokenAsync_WhenJwt_ShouldBuildDescriptorFromContext keyId: "key-1"); var context = CreateTokenContext( - claims: new Dictionary - { - ["role"] = "admin", - ["permission"] = "products.read" - }); + claims: ClaimsSnapshot.From( + ("role", "admin"), + ("permission", "products.read") + )); UAuthJwtTokenDescriptor? captured = null; @@ -242,7 +241,7 @@ await fixture.Sut.IssueAccessTokenAsync( .ContainKey("tenant"); captured.Claims["tenant"].Should() - .Be(context.Tenant); + .Be(context.Tenant.Value); } [Fact] @@ -277,7 +276,7 @@ await fixture.Sut.IssueAccessTokenAsync( .ContainKey("sid"); captured.Claims!["sid"].Should() - .Be(sessionId); + .Be(sessionId.Value); result.SessionId.Should() .Be(sessionId.ToString()); @@ -685,12 +684,12 @@ public async Task IssueAccessTokenAsync_WhenCustomClaimsContainReservedClaims_Sh var flow = CreateFlow(UAuthMode.PureJwt); var context = CreateTokenContext( - claims: new Dictionary - { - ["sub"] = "attacker-user", - ["tenant"] = "attacker-tenant", - ["role"] = "admin" - }); + claims: ClaimsSnapshot.From( + + ("sub", "attacker-user"), + ("tenant", "attacker-tenant"), + ("role", "admin") + )); UAuthJwtTokenDescriptor? captured = null; @@ -719,7 +718,7 @@ await fixture.Sut.IssueAccessTokenAsync( .ContainKey("tenant"); captured.Claims["tenant"].Should() - .Be(context.Tenant); + .Be(context.Tenant.Value); // Non-reserved custom claims must still flow through normally. captured.Claims.Should() @@ -820,6 +819,255 @@ private static Fixture CreateFixture() clock); } + [Theory] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public async Task IssueAccessTokenAsync_WhenMultipleRolesExist_ShouldPreserveAllRoles( + UAuthMode mode) + { + var fixture = CreateFixture(); + var flow = CreateFlow(mode); + + var context = CreateTokenContext( + claims: ClaimsSnapshot.From( + ("role", "Admin"), + ("role", "Editor"), + ("role", "Auditor"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured.Should().NotBeNull(); + captured!.Claims.Should().ContainKey("role"); + + var roles = captured.Claims!["role"] + .Should().BeAssignableTo().Subject; + + roles.Should().BeEquivalentTo( + new[] { "Admin", "Editor", "Auditor" }); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenMultiplePermissionsExist_ShouldPreserveAllPermissions() + { + var fixture = CreateFixture(); + var flow = CreateFlow(UAuthMode.PureJwt); + + var context = CreateTokenContext( + claims: ClaimsSnapshot.From( + ("uauth:permission", "users.read"), + ("uauth:permission", "users.write"), + ("uauth:permission", "users.delete"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + var permissions = captured!.Claims!["uauth:permission"] + .Should().BeAssignableTo().Subject; + + permissions.Should().BeEquivalentTo( + new[] { "users.read", "users.write", "users.delete" }); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenSingleClaimExists_ShouldUseStringValue() + { + var fixture = CreateFixture(); + var flow = CreateFlow(UAuthMode.PureJwt); + + var context = CreateTokenContext( + claims: ClaimsSnapshot.From( + ("department", "legal"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured!.Claims!["department"] + .Should().Be("legal"); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenClaimsAreEmpty_ShouldOnlyIncludeFrameworkClaims() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: false); + + var context = CreateTokenContext( + sessionId: null, + claims: ClaimsSnapshot.Empty); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken( + It.IsAny())) + .Callback( + descriptor => captured = descriptor) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured.Should().NotBeNull(); + + captured!.Claims.Should().HaveCount(2); + + captured.Claims!["sub"] + .Should().Be(context.UserKey.Value); + + captured.Claims["tenant"] + .Should().Be(context.Tenant.Value); + + captured.Claims.Should().NotContainKey("sid"); + captured.Claims.Should().NotContainKey("jti"); + } + + [Theory] + [InlineData("sid")] + [InlineData("jti")] + [InlineData("iss")] + [InlineData("aud")] + [InlineData("exp")] + [InlineData("nbf")] + [InlineData("iat")] + public async Task IssueAccessTokenAsync_WhenCustomClaimIsReserved_ShouldIgnoreIt(string reservedType) + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: false); + + var context = CreateTokenContext( + sessionId: null, + claims: ClaimsSnapshot.From( + (reservedType, "attacker-value"), + ("department", "legal"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured.Should().NotBeNull(); + + captured!.Claims.Should() + .NotContainKey(reservedType); + + captured.Claims!["department"] + .Should().Be("legal"); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenCustomSidExists_ShouldUseFrameworkSessionId() + { + var fixture = CreateFixture(); + var flow = CreateFlow(UAuthMode.PureJwt); + + var sessionId = CreateSessionId( + "trusted-session-000000000000000000000001"); + + var context = CreateTokenContext( + sessionId: sessionId, + claims: ClaimsSnapshot.From( + ("sid", "attacker-session"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured!.Claims!["sid"] + .Should().Be(sessionId.Value); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenCustomJtiExists_ShouldUseGeneratedJti() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: true); + + var context = CreateTokenContext( + claims: ClaimsSnapshot.From( + ("jti", "attacker-jti"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured!.Claims!["jti"] + .Should().Be(JwtId); + + fixture.OpaqueGenerator.Verify( + x => x.GenerateJwtId(), + Times.Once); + } + + [Fact] + public async Task IssueAccessTokenAsync_WhenJwtIdDisabled_ShouldRejectCustomJti() + { + var fixture = CreateFixture(); + + var flow = CreateFlow( + UAuthMode.PureJwt, + addJwtIdClaim: false); + + var context = CreateTokenContext( + claims: ClaimsSnapshot.From( + ("jti", "attacker-jti"))); + + UAuthJwtTokenDescriptor? captured = null; + + fixture.JwtGenerator + .Setup(x => x.CreateToken(It.IsAny())) + .Callback(d => captured = d) + .Returns(Jwt); + + await fixture.Sut.IssueAccessTokenAsync(flow, context); + + captured!.Claims.Should().NotContainKey("jti"); + + fixture.OpaqueGenerator.Verify( + x => x.GenerateJwtId(), + Times.Never); + } + // ===================================================================== // Context helpers // ===================================================================== @@ -865,10 +1113,7 @@ private static AuthFlowContext CreateFlow( return flow; } - private static TokenIssuanceContext CreateTokenContext( - AuthSessionId? sessionId = null, - SessionChainId? chainId = null, - IReadOnlyDictionary? claims = null) + private static TokenIssuanceContext CreateTokenContext(AuthSessionId? sessionId = null, SessionChainId? chainId = null, ClaimsSnapshot? claims = null) { return new TokenIssuanceContext { @@ -876,20 +1121,13 @@ private static TokenIssuanceContext CreateTokenContext( Tenant = Tenant, SessionId = sessionId, ChainId = chainId, - Claims = claims ?? - new Dictionary(), - IssuedAt = Now + Claims = claims ?? ClaimsSnapshot.Empty }; } - private static AuthSessionId CreateSessionId( - string value) + private static AuthSessionId CreateSessionId(string value) { - AuthSessionId.TryCreate( - value, - out var id) - .Should() - .BeTrue(); + AuthSessionId.TryCreate(value, out var id).Should().BeTrue(); return id; } From b1342e54df90836a0e10f0838c21754b351ca207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 17:34:02 +0300 Subject: [PATCH 16/26] fix(client): ensure response validation before state events --- .../Infrastructure/UAuthRequestClient.cs | 3 - .../TScripts/uauth.js | 39 +++- .../wwwroot/uauth.min.js | 2 +- .../Infrastructure/UAuthResultMapper.cs | 43 ++-- .../Services/Abstractions/IUserClient.cs | 4 +- .../Services/UAuthCredentialClient.cs | 36 ++-- .../Services/UAuthFlowClient.cs | 204 ++++++++++++++---- .../Services/UAuthUserClient.cs | 71 +++--- .../Services/UAuthUserIdentifierClient.cs | 40 ++-- .../UAuthClientEventConsistencyTests.cs | 204 ++++++++++++++++++ .../Client/UAuthClientSessionTests.cs | 81 ++++++- .../Client/UAuthClientUserTests.cs | 120 ++++++++++- .../Client/UAuthResultMapperTests.cs | 116 +++++++++- .../Helpers/UAuthClientTestBase.cs | 8 +- 14 files changed, 814 insertions(+), 157 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 38c7fe96..510acac1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -83,9 +83,6 @@ public async Task SendJsonAsync(string endpoint, object? p if (result.Status == 0) throw new UAuthTransportException("Network error."); - //if (result.Status >= 500) - // throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); - return result; } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 5f204f3a..01565046 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -57,20 +57,45 @@ window.uauth.submitForm = function (form) { window.uauth.tryAndCommit = async function (options) { const { tryUrl, commitUrl, data, clientProfile } = options; + const deviceId = window.uauth.deviceId; + + if (!deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + const tryResponse = await window.uauth.postJson({ url: tryUrl, payload: data, - clientProfile: clientProfile + clientProfile }); - let result = tryResponse?.body; + if (!tryResponse || !Number.isInteger(tryResponse.status)) { + throw new Error("Invalid UAuth transport response."); + } + + const status = tryResponse.status; + const isHttpSuccess = status >= 200 && status < 300; + const isClientError = status >= 400 && status < 500; - if (!result) { - result = {}; + if (!isHttpSuccess && !isClientError) { + throw new Error(`Unexpected UAuth try response: HTTP ${status}.`); + } + + const result = tryResponse.body; + + if (!result || + typeof result !== "object" || + Array.isArray(result) || + typeof result.isSuccess !== "boolean") { + throw new Error("Invalid UAuth try response body."); + } + + if (isClientError && result.isSuccess) { + throw new Error("Successful authentication result returned with HTTP error."); } const normalized = { - isSuccess: result.isSuccess ?? false, + isSuccess: result.isSuccess, reason: result.reason ?? null, remainingAttempts: result.remainingAttempts ?? null, lockoutUntilUtc: result.lockoutUntilUtc ?? null, @@ -84,7 +109,7 @@ window.uauth.tryAndCommit = async function (options) { form.method = "POST"; form.action = commitUrl; - for (const key in data) { + for (const key of Object.keys(data)) { const input = document.createElement("input"); input.type = "hidden"; input.name = key; @@ -109,7 +134,7 @@ window.uauth.tryAndCommit = async function (options) { const udid = document.createElement("input"); udid.type = "hidden"; udid.name = "__uauth_device"; - udid.value = window.uauth.deviceId; + udid.value = deviceId; form.appendChild(udid); document.body.appendChild(form); diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 09a38bf1..197576de 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:r,clientProfile:u}=n,o=await window.uauth.postJson({url:f,payload:r,clientProfile:u});let t=o?.body;t||(t={});const i={isSuccess:t.isSuccess??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(i.isSuccess){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in r){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=r[t]??"";n.appendChild(i)}if(i.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=i.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const f=document.createElement("input");f.type="hidden";f.name="__uauth_device";f.value=window.uauth.deviceId;n.appendChild(f);document.body.appendChild(n);n.submit()}return i};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:h,commitUrl:c,data:f,clientProfile:e}=n,o=window.uauth.deviceId;if(!o)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:h,payload:f,clientProfile:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,l=i>=200&&i<300,s=i>=400&&i<500;if(!l&&!s)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(s&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=c;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=e??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=o;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 2156fafa..5b7ee29e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -8,39 +8,36 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal static class UAuthResultMapper { - private static readonly JsonSerializerOptions _jsonOptions = - new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; public static UAuthResult FromJson(UAuthTransportResult raw) { EnsureTransport(raw); - if (raw.Status >= 400 && raw.Status < 500) + if (!IsSuccessStatus(raw.Status)) { - var problem = TryDeserializeProblem(raw); - return new UAuthResult { IsSuccess = false, Status = raw.Status, - Problem = problem + Problem = TryDeserializeProblem(raw) }; } - if (raw.Body is null) + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { - return new UAuthResult - { - IsSuccess = true, - Status = raw.Status, - Value = default - }; + throw new UAuthProtocolException($"Expected response body for {typeof(T).Name}, but received none."); } try { var value = raw.Body.Value.Deserialize(_jsonOptions); + if (value is null) + { + throw new UAuthProtocolException($"Response body for {typeof(T).Name} was null."); + } + return new UAuthResult { IsSuccess = true, @@ -54,13 +51,29 @@ public static UAuthResult FromJson(UAuthTransportResult raw) } } - public static UAuthResult From(UAuthTransportResult raw) => FromJson(raw); + public static UAuthResult From(UAuthTransportResult raw) + { + EnsureTransport(raw); + + return new UAuthResult + { + IsSuccess = IsSuccessStatus(raw.Status), + Status = raw.Status, + Problem = IsSuccessStatus(raw.Status) ? null : TryDeserializeProblem(raw) + }; + } + //public static UAuthResult From(UAuthTransportResult raw) => FromJson(raw); + + private static bool IsSuccessStatus(int status) => status is >= 200 and < 300; private static void EnsureTransport(UAuthTransportResult raw) { if (raw.Status == 0) throw new UAuthTransportException("Network error."); + if (raw.Status < 200 || raw.Status is >= 300 and < 400 || raw.Status >= 600) + throw new UAuthProtocolException($"Unexpected HTTP status code: {raw.Status}"); + if (raw.Status >= 500) throw new UAuthTransportException($"Server error {raw.Status}", (HttpStatusCode)raw.Status); } @@ -74,7 +87,7 @@ private static void EnsureTransport(UAuthTransportResult raw) { return raw.Body.Value.Deserialize(_jsonOptions); } - catch + catch (JsonException) { return null; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs index dac08dbb..5583aec9 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -125,7 +125,7 @@ public interface IUserClient /// /// The default profile cannot be deleted. /// - Task DeleteMyProfileAsync(ProfileKey profileKey); + Task DeleteMyProfileAsync(DeleteProfileRequest request); /// @@ -154,5 +154,5 @@ public interface IUserClient /// /// Deletes a profile of a specific user. /// - Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey); + Task DeleteUserProfileAsync(UserKey userKey, DeleteProfileRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 1d02db68..5a0fbeb2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -32,21 +32,29 @@ public async Task> AddMyAsync(AddCredentialRequ public async Task> ChangeMyAsync(ChangeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url("/me/credentials/change"), request); - if (raw.Ok) + var result = UAuthResultMapper.FromJson(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.StateEvents.HandlingMode)); + await _events.PublishAsync( + new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.FromJson(raw); + + return result; } public async Task RevokeMyAsync(RevokeCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/me/credentials/revoke"), request); - if (raw.Ok) + var raw = await _request.SendJsonAsync(Url("/me/credentials/revoke"), request); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + await _events.PublishAsync( + new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + + return result; } public async Task> BeginResetMyAsync(BeginResetCredentialRequest request) @@ -57,14 +65,18 @@ public async Task> BeginResetMyAsync(Beg public async Task> CompleteResetMyAsync(CompleteResetCredentialRequest request) { - var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/complete"), request); - if (raw.Ok) + var raw = await _request.SendJsonAsync(Url("/me/credentials/reset/complete"), request); + + var result = UAuthResultMapper.FromJson(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + await _events.PublishAsync( + new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.FromJson(raw); - } + return result; + } public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) { diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 3bd89207..eea71c18 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -63,27 +63,34 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi { case UAuthSubmitMode.TryOnly: { - var result = await _post.SendJsonAsync(tryUrl, payload); + var raw = await _post.SendJsonAsync(tryUrl, payload); - if (result.Body is null) - throw new UAuthProtocolException("Empty response body."); + EnsureValidHttpStatus(raw); - TryLoginResult parsed; + if (raw.Status is >= 400 and < 500) + { + throw new UAuthProtocolException($"Unexpected client error during try-login: {raw.Status}."); + } + + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new UAuthProtocolException("Empty try-login response body."); + } try { - parsed = result.Body.Value.Deserialize( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + var parsed = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return parsed ?? throw new UAuthProtocolException("Invalid try-login result."); } catch (JsonException ex) { throw new UAuthProtocolException("Invalid try-login result.", ex); } - - if (parsed is null) - throw new UAuthProtocolException("Invalid try-login result."); - - return parsed; } case UAuthSubmitMode.DirectCommit: @@ -111,55 +118,123 @@ public async Task LogoutAsync() await _post.NavigateAsync(url); } + public async Task RefreshAsync(bool isAuto = false) { - if (isAuto == false) + if (!isAuto) { _diagnostics.MarkManualRefresh(); } - var url = Url(_options.Endpoints.Refresh); - var result = await _post.SendFormAsync(url); + var raw = await _post.SendFormAsync(Url(_options.Endpoints.Refresh)); - if (result.Status == 401) + // 401 is a normal authentication outcome for refresh. + if (raw.Status == 401) { _diagnostics.MarkRefreshReauthRequired(); + return new RefreshResult { IsSuccess = false, - Status = result.Status, + Status = 401, Outcome = RefreshOutcome.ReauthRequired }; } - var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); - switch (refreshOutcome) + EnsureValidHttpStatus(raw); + + var outcome = RefreshOutcomeParser.Parse(raw.RefreshOutcome); + var isSuccess = raw.Status is >= 200 and < 300 && raw.Ok; + + // Never record a successful refresh outcome + // for a failed HTTP response. + if (isSuccess) { - case RefreshOutcome.NoOp: - _diagnostics.MarkRefreshNoOp(); - break; - case RefreshOutcome.Touched: - _diagnostics.MarkRefreshTouched(); - break; - case RefreshOutcome.Rotated: - _diagnostics.MarkRefreshRotated(); - break; - case RefreshOutcome.ReauthRequired: - _diagnostics.MarkRefreshReauthRequired(); - break; - case RefreshOutcome.Success: - _diagnostics.MarkRefreshSuccess(); - break; + switch (outcome) + { + case RefreshOutcome.NoOp: + _diagnostics.MarkRefreshNoOp(); + break; + + case RefreshOutcome.Touched: + _diagnostics.MarkRefreshTouched(); + break; + + case RefreshOutcome.Rotated: + _diagnostics.MarkRefreshRotated(); + break; + + case RefreshOutcome.ReauthRequired: + _diagnostics.MarkRefreshReauthRequired(); + break; + + case RefreshOutcome.Success: + _diagnostics.MarkRefreshSuccess(); + break; + } + } + else if (outcome == RefreshOutcome.ReauthRequired) + { + _diagnostics.MarkRefreshReauthRequired(); } return new RefreshResult { - IsSuccess = result.Ok, - Status = result.Status, - Outcome = refreshOutcome + IsSuccess = isSuccess, + Status = raw.Status, + Outcome = outcome }; } + //public async Task RefreshAsync(bool isAuto = false) + //{ + // if (isAuto == false) + // { + // _diagnostics.MarkManualRefresh(); + // } + + // var url = Url(_options.Endpoints.Refresh); + // var result = await _post.SendFormAsync(url); + + // if (result.Status == 401) + // { + // _diagnostics.MarkRefreshReauthRequired(); + // return new RefreshResult + // { + // IsSuccess = false, + // Status = result.Status, + // Outcome = RefreshOutcome.ReauthRequired + // }; + // } + + // var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); + // switch (refreshOutcome) + // { + // case RefreshOutcome.NoOp: + // _diagnostics.MarkRefreshNoOp(); + // break; + // case RefreshOutcome.Touched: + // _diagnostics.MarkRefreshTouched(); + // break; + // case RefreshOutcome.Rotated: + // _diagnostics.MarkRefreshRotated(); + // break; + // case RefreshOutcome.ReauthRequired: + // _diagnostics.MarkRefreshReauthRequired(); + // break; + // case RefreshOutcome.Success: + // _diagnostics.MarkRefreshSuccess(); + // break; + // } + + // return new RefreshResult + // { + // IsSuccess = result.Ok, + // Status = result.Status, + // Outcome = refreshOutcome + // }; + //} + //public async Task ReauthAsync() //{ // var url = Url(_options.Endpoints.Reauth); @@ -289,16 +364,32 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ { var raw = await _post.SendJsonAsync(tryUrl, request); - if (raw.Body is null) - throw new UAuthProtocolException("Empty response body."); + EnsureValidHttpStatus(raw); - var parsed = raw.Body.Value.Deserialize( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (raw.Status is >= 400 and < 500) + { + throw new UAuthProtocolException($"Unexpected client error during PKCE try-login: {raw.Status}."); + } - if (parsed is null) - throw new UAuthProtocolException("Invalid PKCE try result."); + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new UAuthProtocolException("Empty PKCE try-login response body."); + } - return parsed; + try + { + var parsed = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return parsed ?? throw new UAuthProtocolException("Invalid PKCE try-login result."); + } + catch (JsonException ex) + { + throw new UAuthProtocolException("Invalid PKCE try-login result.", ex); + } } case UAuthSubmitMode.TryAndCommit: @@ -382,11 +473,15 @@ public async Task LogoutUserOtherDevicesAsync(UserKey userKey, Logo public async Task LogoutAllMyDevicesAsync() { var raw = await _post.SendJsonAsync(Url("/me/logout-all")); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + await _events.PublishAsync( + new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + + return result; } public async Task LogoutAllUserDevicesAsync(UserKey userKey) @@ -457,4 +552,21 @@ private static string CreateChallenge(string verifier) var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); return Base64Url.Encode(hash); } + + private static void EnsureValidHttpStatus(UAuthTransportResult raw) + { + if (raw is null) + throw new UAuthProtocolException("Transport client returned a null response."); + + if (raw.Status == 0) + throw new UAuthTransportException("Network error."); + + if (raw.Status >= 500 && raw.Status <= 599) + throw new UAuthTransportException($"Server error {raw.Status}.", (HttpStatusCode)raw.Status); + + if (raw.Status < 200 || raw.Status is >= 300 and < 400 || raw.Status >= 600) + { + throw new UAuthProtocolException($"Unexpected HTTP status code: {raw.Status}."); + } + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 84f41dcf..58220bab 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -33,21 +33,29 @@ public async Task> GetMeAsync(GetProfileRequest? request = public async Task UpdateMyProfileAsync(UpdateProfileRequest request) { var raw = await _request.SendJsonAsync(Url("/me/profile/update"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + await _events.PublishAsync( + new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); } - return UAuthResultMapper.From(raw); + + return result; } public async Task DeleteMeAsync() { var raw = await _request.SendJsonAsync(Url("/me/delete")); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); + await _events.PublishAsync( + new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); } - return UAuthResultMapper.From(raw); + + return result; } public async Task>> QueryAsync(UserQuery query) @@ -61,16 +69,15 @@ public async Task CreateMyProfileAsync(CreateProfileRequest request { var raw = await _request.SendJsonAsync(Url("/me/profile/create"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync( - new UAuthStateEventArgs( - UAuthStateEvent.ProfileChanged, - _options.StateEvents.HandlingMode, - request)); + new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); } - return UAuthResultMapper.From(raw); + return result; } public async Task CreateUserProfileAsync(UserKey userKey, CreateProfileRequest request) @@ -85,38 +92,24 @@ public async Task> CreateAsync(CreateUserRequest r return UAuthResultMapper.FromJson(raw); } - public async Task DeleteMyProfileAsync(ProfileKey profileKey) + public async Task DeleteMyProfileAsync(DeleteProfileRequest request) { - var request = new DeleteProfileRequest - { - ProfileKey = profileKey - }; - var raw = await _request.SendJsonAsync(Url("/me/profile/delete"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync( - new UAuthStateEventArgs( - UAuthStateEvent.ProfileChanged, - _options.StateEvents.HandlingMode, - profileKey)); + new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request.ProfileKey)); } - return UAuthResultMapper.From(raw); + return result; } - public async Task DeleteUserProfileAsync(UserKey userKey, ProfileKey profileKey) + public async Task DeleteUserProfileAsync(UserKey userKey, DeleteProfileRequest request) { - var request = new DeleteProfileRequest - { - ProfileKey = profileKey - }; - - var raw = await _request.SendJsonAsync( - Url($"/admin/users/{userKey.Value}/profile/delete"), - request); - + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/delete"), request); return UAuthResultMapper.From(raw); } @@ -129,11 +122,15 @@ public async Task> CreateAsAdminAsync(CreateUserRe public async Task> ChangeMyStatusAsync(ChangeUserStatusSelfRequest request) { var raw = await _request.SendJsonAsync(Url("/me/status"), request); - if (raw.Ok) + var result = UAuthResultMapper.FromJson(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + await _events.PublishAsync( + new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); } - return UAuthResultMapper.FromJson(raw); + + return result; } public async Task> ChangeUserStatusAsync(UserKey userKey, ChangeUserStatusAdminRequest request) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index c81a8746..2c21b0f9 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -33,61 +33,75 @@ public async Task>> GetMyAsync(PageR public async Task AddMyAsync(AddUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/add"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { - await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); + await _events.PublishAsync( + new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); } - return UAuthResultMapper.From(raw); + + return result; } public async Task UpdateMyAsync(UpdateUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/update"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); } - return UAuthResultMapper.From(raw); + return result; } public async Task SetMyPrimaryAsync(SetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/set-primary"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + return result; } public async Task UnsetMyPrimaryAsync(UnsetPrimaryUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/unset-primary"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + return result; } public async Task VerifyMyAsync(VerifyUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/verify"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + return result; } public async Task DeleteMyAsync(DeleteUserIdentifierRequest request) { var raw = await _request.SendJsonAsync(Url("/me/identifiers/delete"), request); - if (raw.Ok) + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); } - return UAuthResultMapper.From(raw); + return result; } public async Task>> GetUserAsync(UserKey userKey, PageRequest? request = null) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs new file mode 100644 index 00000000..f15fb9d1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs @@ -0,0 +1,204 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientEventConsistencyTests +{ + private readonly Mock _request = new(); + private readonly Mock _events = new(); + + private readonly IOptions _options = + Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + private void Respond(int status, bool ok = true) + { + _request.Setup(x => x.SendJsonAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = ok, + Status = status + }); + } + + private void AssertNoEvents() + { + _events.Verify( + x => x.PublishAsync(It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData("Add")] + [InlineData("Update")] + [InlineData("SetPrimary")] + [InlineData("UnsetPrimary")] + [InlineData("Verify")] + [InlineData("Delete")] + public async Task Identifier_Should_Not_Publish_When_Response_Is_Invalid( + string operation) + { + Respond(302); + + var client = new UAuthUserIdentifierClient( + _request.Object, _events.Object, _options); + + // Requests are deliberately omitted: this test only + // exercises HTTP response validation and event ordering. + Func act = operation switch + { + "Add" => () => client.AddMyAsync(null!), + "Update" => () => client.UpdateMyAsync(null!), + "SetPrimary" => () => client.SetMyPrimaryAsync(null!), + "UnsetPrimary" => () => client.UnsetMyPrimaryAsync(null!), + "Verify" => () => client.VerifyMyAsync(null!), + "Delete" => () => client.DeleteMyAsync(null!), + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + + await act.Should().ThrowAsync(); + AssertNoEvents(); + } + + [Theory] + [InlineData("UpdateProfile")] + [InlineData("DeleteMe")] + [InlineData("CreateProfile")] + [InlineData("DeleteProfile")] + public async Task User_Should_Not_Publish_When_Response_Is_Invalid(string operation) + { + Respond(302); + + var client = new UAuthUserClient(_request.Object, _events.Object, _options); + + Func act = operation switch + { + "UpdateProfile" => () => client.UpdateMyProfileAsync(null!), + "DeleteMe" => () => client.DeleteMeAsync(), + "CreateProfile" => () => client.CreateMyProfileAsync(null!), + "DeleteProfile" => () => client.DeleteMyProfileAsync(null!), + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + + await act.Should().ThrowAsync(); + AssertNoEvents(); + } + + [Theory] + [InlineData("Change")] + [InlineData("Revoke")] + [InlineData("CompleteReset")] + public async Task Credential_Should_Not_Publish_When_Response_Is_Invalid(string operation) + { + Respond(302); + + var client = new UAuthCredentialClient( + _request.Object, _events.Object, _options); + + Func act = operation switch + { + "Change" => () => client.ChangeMyAsync(null!), + "Revoke" => () => client.RevokeMyAsync(null!), + "CompleteReset" => () => client.CompleteResetMyAsync(null!), + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + + await act.Should().ThrowAsync(); + AssertNoEvents(); + } + + [Theory] + [InlineData("Change")] + [InlineData("CompleteReset")] + public async Task Typed_Credential_Response_Should_Not_Publish_When_Body_Missing( + string operation) + { + Respond(200); + + var client = new UAuthCredentialClient( + _request.Object, _events.Object, _options); + + Func act = operation switch + { + "Change" => () => client.ChangeMyAsync(null!), + "CompleteReset" => () => client.CompleteResetMyAsync(null!), + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + + await act.Should().ThrowAsync(); + AssertNoEvents(); + } + + [Fact] + public async Task Identifier_Should_Not_Publish_On_Server_Error() + { + Respond(500, ok: false); + + var client = new UAuthUserIdentifierClient( + _request.Object, _events.Object, _options); + + Func act = () => client.AddMyAsync(null!); + + await act.Should().ThrowAsync(); + AssertNoEvents(); + } + + [Fact] + public async Task Identifier_Should_Publish_On_Valid_Success() + { + Respond(204); + + var request = new AddUserIdentifierRequest + { + Value = "test@example.com", + Type = UserIdentifierType.Email, + IsPrimary = false + }; + + var client = new UAuthUserIdentifierClient( + _request.Object, _events.Object, _options); + + var result = await client.AddMyAsync(request); + + result.IsSuccess.Should().BeTrue(); + result.Status.Should().Be(204); + + _events.Verify(x => x.PublishAsync( + It.Is>(e => + e.Type == UAuthStateEvent.IdentifiersChanged && + ReferenceEquals(e.Payload, request))), + Times.Once); + } + + [Fact] + public async Task Identifier_Should_Return_Failure_Without_Event_On_4xx() + { + Respond(409, ok: false); + + var client = new UAuthUserIdentifierClient( + _request.Object, _events.Object, _options); + + var result = await client.AddMyAsync(null!); + + result.IsSuccess.Should().BeFalse(); + result.Status.Should().Be(409); + + AssertNoEvents(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs index a6f929b2..ab42fc8f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs @@ -1,11 +1,13 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Client.Events; using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Client.Services; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; using Microsoft.Extensions.Options; using Moq; using System.Text.Json; @@ -246,7 +248,7 @@ public async Task RevokeUserRoot_Should_Call_Correct_Endpoint() } [Fact] - public async Task RevokeMyChain_Should_NOT_Publish_Event_When_Value_Null() + public async Task RevokeMyChain_Should_NOT_Publish_Event_When_Response_Body_Missing() { _request.Setup(x => x.SendJsonAsync(It.IsAny())) .ReturnsAsync(new UAuthTransportResult @@ -258,7 +260,80 @@ public async Task RevokeMyChain_Should_NOT_Publish_Event_When_Value_Null() var client = CreateClient(); var chainId = SessionChainId.New(); - await client.Sessions.RevokeMyChainAsync(chainId); - _events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + + await FluentActions + .Invoking(() => client.Sessions.RevokeMyChainAsync(chainId)) + .Should() + .ThrowAsync(); + + _events.Verify( + x => x.PublishAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RevokeMyChain_Should_Not_Publish_Event_On_4xx() + { + _request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = false, + Status = 403 + }); + + var client = CreateClient(); + + var result = await client.Sessions.RevokeMyChainAsync( + SessionChainId.New()); + + result.IsSuccess.Should().BeFalse(); + result.Status.Should().Be(403); + + _events.Verify( + x => x.PublishAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RevokeMyChain_Should_Not_Publish_Event_On_5xx() + { + _request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = false, + Status = 500 + }); + + var client = CreateClient(); + + Func act = () => + client.Sessions.RevokeMyChainAsync(SessionChainId.New()); + + await act.Should().ThrowAsync(); + + _events.Verify( + x => x.PublishAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RevokeUserChain_Should_Throw_When_Body_Missing() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = null + }); + + var client = CreateClient(); + + Func act = () => + client.Sessions.RevokeUserChainAsync( + UserKey.FromString("user-1"), + SessionChainId.New()); + + await act.Should().ThrowAsync(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs index ec486bc7..b7c319ff 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs @@ -1,10 +1,13 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; using Moq; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -200,15 +203,122 @@ public async Task Query_Should_Create_Default_Query_When_Null() [Fact] public async Task Query_Should_Use_Given_Query() { - var query = new UserQuery + var query = new UserQuery(); + + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + Request.Setup(x => + x.SendJsonAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateUserClient(); + + var result = await client.Users.QueryAsync(query); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + + Request.Verify(x => + x.SendJsonAsync( + "/auth/admin/users/query", + It.Is(o => ReferenceEquals(o, query))), + Times.Once); + } + + [Fact] + public async Task ChangeMyStatus_Should_Not_Publish_Event_When_Body_Missing() + { + var request = new ChangeUserStatusSelfRequest { + NewStatus = SelfAssignableUserStatus.Active }; - Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(Success()); + Request.Setup(x => + x.SendJsonAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = null + }); var client = CreateUserClient(); - await client.Users.QueryAsync(query); - Request.Verify(x => x.SendJsonAsync("/auth/admin/users/query", It.Is(o => ReferenceEquals(o, query))), Times.Once); + + Func act = () => + client.Users.ChangeMyStatusAsync(request); + + await act.Should().ThrowAsync(); + + Events.Verify( + x => x.PublishAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task TryLogin_TryOnly_Should_Throw_On_Server_Error() + { + Request.Setup(x => x.SendJsonAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = false, + Status = 500, + Body = JsonSerializer.SerializeToElement( + new TryLoginResult { IsSuccess = true }) + }); + + var client = CreateFlowClient(); + + Func act = () => client.TryLoginAsync( + new LoginRequest + { + // Fill required fields according to the contract. + }, + UAuthSubmitMode.TryOnly); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_Should_Throw_On_Server_Error() + { + Request.Setup(x => x.SendFormAsync( + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = false, + Status = 503 + }); + + var client = CreateFlowClient(); + + Func act = () => client.RefreshAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_Should_Throw_On_Network_Error() + { + Request.Setup(x => x.SendFormAsync( + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = false, + Status = 0 + }); + + var client = CreateFlowClient(); + + Func act = () => client.RefreshAsync(); + + await act.Should().ThrowAsync(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs index 301f457d..6e7c8c84 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Client.Contracts; using CodeBeam.UltimateAuth.Client.Errors; using CodeBeam.UltimateAuth.Client.Infrastructure; -using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using System.Text.Json; @@ -25,7 +24,7 @@ public void FromJson_Should_Map_Success_Response() } [Fact] - public void FromJson_Should_Handle_Empty_Body() + public void FromJson_Should_Reject_Empty_Body() { var raw = new UAuthTransportResult { @@ -33,10 +32,24 @@ public void FromJson_Should_Handle_Empty_Body() Body = null }; - var result = UAuthResultMapper.FromJson(raw); + Action act = () => UAuthResultMapper.FromJson(raw); + + act.Should().Throw(); + } + + [Fact] + public void From_Should_Handle_NoContent() + { + var raw = new UAuthTransportResult + { + Status = 204, + Body = null + }; + + var result = UAuthResultMapper.From(raw); result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeNull(); + result.Status.Should().Be(204); } [Fact] @@ -93,7 +106,7 @@ public void FromJson_Should_Be_Case_Insensitive() } [Fact] - public void FromJson_Should_Handle_NullBody_With_Generic_Type() + public void FromJson_Should_Reject_NullBody_With_Generic_Type() { var raw = new UAuthTransportResult { @@ -101,10 +114,9 @@ public void FromJson_Should_Handle_NullBody_With_Generic_Type() Body = null }; - var result = UAuthResultMapper.FromJson(raw); + Action act = () => UAuthResultMapper.FromJson(raw); - result.IsSuccess.Should().BeTrue(); - result.Value.Should().BeNull(); + act.Should().Throw(); } [Fact] @@ -133,4 +145,92 @@ public void FromJson_Should_Throw_On_Status_Zero() Action act = () => UAuthResultMapper.FromJson(raw); act.Should().Throw(); } + + [Fact] + public void FromJson_ShouldDeserializeValidBody() + { + var raw = Response(200, new + { + name = "UltimateAuth", + count = 5 + }); + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeTrue(); + result.Status.Should().Be(200); + result.Value.Should().NotBeNull(); + result.Value!.Name.Should().Be("UltimateAuth"); + result.Value.Count.Should().Be(5); + } + + [Fact] + public void From_ShouldAccept4xxWithoutProblemBody() + { + var raw = Response(401); + + var result = UAuthResultMapper.From(raw); + + result.IsSuccess.Should().BeFalse(); + result.Status.Should().Be(401); + result.Problem.Should().BeNull(); + } + + [Theory] + [InlineData(500)] + [InlineData(502)] + [InlineData(503)] + [InlineData(504)] + public void FromJson_ShouldThrowTransportExceptionFor5xx(int status) + { + var raw = Response(status); + + Action act = () => UAuthResultMapper.FromJson(raw); + + act.Should().Throw(); + } + + [Theory] + [InlineData(301)] + [InlineData(302)] + [InlineData(307)] + [InlineData(308)] + public void FromJson_ShouldRejectRedirectResponses(int status) + { + var raw = Response(status); + + Action act = () => + UAuthResultMapper.FromJson(raw); + + act.Should().Throw(); + } + + [Fact] + public void From_ShouldRejectInvalidHttpStatus() + { + var raw = Response(600); + + Action act = () => UAuthResultMapper.From(raw); + + act.Should().Throw(); + } + + + private sealed class TestDto + { + public string? Name { get; set; } + public int Count { get; set; } + } + + private static UAuthTransportResult Response(int status, object? body = null) + { + return new UAuthTransportResult + { + Ok = status is >= 200 and < 300, + Status = status, + Body = body is null + ? null + : JsonSerializer.SerializeToElement(body) + }; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs index 05b99323..7ceb6c4e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs @@ -44,12 +44,10 @@ protected IUAuthClient CreateClient( ); } - protected IFlowClient CreateFlowClient( - Mock? requestMock = null, - Mock? eventsMock = null) + protected IFlowClient CreateFlowClient(Mock? requestMock = null, Mock? eventsMock = null) { - var request = requestMock ?? new Mock(); - var events = eventsMock ?? new Mock(); + var request = requestMock ?? Request; + var events = eventsMock ?? Events; var deviceProvider = new Mock(); deviceProvider.Setup(x => x.GetAsync()) From 85fcf72c36980e0b22f36b21b8e889dbe0c75e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 17:57:00 +0300 Subject: [PATCH 17/26] fix(core): enforce immutability in claims and auth snapshots --- .../Contracts/Auth/AuthIdentitySnapshot.cs | 2 +- .../Contracts/Authority/AuthContext.cs | 2 +- .../Domain/Session/ClaimsSnapshot.cs | 23 +++++++++++++-- .../Core/ClaimsSnapshotTests.cs | 29 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ClaimsSnapshotTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs index 4f3812bf..7b0e459b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -14,5 +14,5 @@ public sealed record AuthIdentitySnapshot public DateTimeOffset? AuthenticatedAt { get; init; } public SessionState? SessionState { get; init; } public string? TimeZone { get; init; } - public UserStatus UserStatus { get; set; } + public UserStatus UserStatus { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index 0424ad16..d4b6799c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record AuthContext { - public UAuthClientProfile ClientProfile { get; set; } + public UAuthClientProfile ClientProfile { get; init; } public TenantKey Tenant { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 309f961b..7e1d1df8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Collections.ObjectModel; +using System.Security.Claims; using System.Text.Json.Serialization; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -21,7 +22,25 @@ public sealed class ClaimsSnapshot [JsonConstructor] public ClaimsSnapshot(IReadOnlyDictionary> claims) { - _claims = claims; + ArgumentNullException.ThrowIfNull(claims); + var copy = new Dictionary>(StringComparer.Ordinal); + + foreach (var (type, values) in claims) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentNullException.ThrowIfNull(values); + + var items = values.ToArray(); + + if (items.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("Claim values cannot be null or whitespace.", nameof(claims)); + } + + copy.Add(type, Array.AsReadOnly(items)); + } + + _claims = new ReadOnlyDictionary>(copy); } public static ClaimsSnapshot Empty { get; } = new(new Dictionary>()); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ClaimsSnapshotTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ClaimsSnapshotTests.cs new file mode 100644 index 00000000..6877d718 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ClaimsSnapshotTests.cs @@ -0,0 +1,29 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClaimsSnapshotTests +{ + [Fact] + public void ClaimsSnapshot_Should_Not_Change_When_Source_Is_Mutated() + { + var roles = new List { "Admin" }; + + var source = + new Dictionary> + { + [ClaimTypes.Role] = roles + }; + + var snapshot = new ClaimsSnapshot(source); + + roles.Add("SuperAdmin"); + source[ClaimTypes.Role] = new List { "Guest" }; + + snapshot.IsInRole("Admin").Should().BeTrue(); + snapshot.IsInRole("SuperAdmin").Should().BeFalse(); + snapshot.IsInRole("Guest").Should().BeFalse(); + } +} From 9b336c06f68b6c17df6d2d53a82f1c37f2c7b798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 21:56:34 +0300 Subject: [PATCH 18/26] fix(core): enforce value object invariants and serialization consistency --- .../Converters/TenantKeyJsonConverter.cs | 9 ++++++- .../MultiTenancy/TenantKey.cs | 13 ++++++++-- .../Auth/Context/AuthFlowContext.cs | 6 ++--- .../Auth/Context/AuthFlowContextFactory.cs | 5 ++-- .../Domain/Permission.cs | 24 +++++++++++++------ .../AuthorizationEndpointHandlerTests.cs | 13 ++++++++++ 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs index b0d42356..1f2922a2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs @@ -19,7 +19,14 @@ public override TenantKey Read(ref Utf8JsonReader reader, Type typeToConvert, Js // IMPORTANT: // JSON transport = internal framework boundary // Do NOT use FromExternal here. - return TenantKey.FromInternal(value); + try + { + return TenantKey.FromInternal(value!); + } + catch (ArgumentException ex) + { + throw new JsonException("Invalid TenantKey.", ex); + } } public override void Write(Utf8JsonWriter writer, TenantKey value, JsonSerializerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs index 0b7d3838..1e44eca7 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -24,11 +24,12 @@ private TenantKey(string value) public bool IsSingle => Value == Single.Value; public bool IsSystem => Value == System.Value; public bool IsUnresolved => Value == Unresolved.Value; + public bool IsValid => !string.IsNullOrWhiteSpace(Value); /// /// True only for real, customer-defined tenants. /// - public bool IsNormal => !IsSingle && !IsSystem && !IsUnresolved; + public bool IsNormal => IsValid && !IsSingle && !IsSystem && !IsUnresolved; public static TenantKey Parse(string s, IFormatProvider? provider) { @@ -84,7 +85,15 @@ public static TenantKey FromExternal(string? value) /// /// Internal creation for framework use only. /// - internal static TenantKey FromInternal(string value) => new(value); + internal static TenantKey FromInternal(string value) + { + if (value == Single.Value || value == System.Value || value == Unresolved.Value) + { + return new TenantKey(value); + } + + return new TenantKey(Normalize(value)); + } private static string Normalize(string value) { diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index 50bd4e05..acef799a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -35,7 +35,7 @@ public sealed class AuthFlowContext Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; public bool IsSingleTenant => Tenant.IsSingle; - public bool IsMultiTenant => !Tenant.IsSingle && !Tenant.IsSystem; + public bool IsMultiTenant => Tenant.IsNormal; internal AuthFlowContext( AuthFlowType flowType, @@ -52,8 +52,8 @@ internal AuthFlowContext( PrimaryTokenKind primaryTokenKind, ReturnUrlInfo returnUrlInfo) { - if (tenantKey.IsUnresolved) - throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + if (!tenantKey.IsValid || tenantKey.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant."); FlowType = flowType; ClientProfile = clientProfile; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 3357fd43..0172602d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; @@ -84,8 +85,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); } - if (tenant.IsUnresolved) - throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + if (!tenant.IsValid || tenant.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant."); // TODO: Implement invariant checker //_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs index 822da088..d8c54b4f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -1,21 +1,31 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; +using System.Text.Json.Serialization; -public readonly record struct Permission(string Value) +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct Permission { - public static Permission From(string value) + public string Value { get; } + + [JsonConstructor] + public Permission(string value) { if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("permission_required"); + throw new ArgumentException("permission_required", nameof(value)); - return new Permission(value.Trim().ToLowerInvariant()); + Value = value.Trim().ToLowerInvariant(); } + public static Permission From(string value) => new(value); + public static readonly Permission Wildcard = new("*"); public bool IsWildcard => Value == "*"; - public bool IsPrefix => Value.EndsWith(".*"); - public override string ToString() => Value; + public bool IsPrefix => Value?.EndsWith(".*", StringComparison.Ordinal) == true; + + public bool IsValid => !string.IsNullOrWhiteSpace(Value); + + public override string ToString() => Value ?? string.Empty; public static implicit operator string(Permission p) => p.Value; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs index e93d3606..8c5e5782 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs @@ -701,6 +701,19 @@ public async Task RenameRoleAsync_WhenBodyContainsDifferentId_ReturnsBadRequest( Times.Never); } + [Fact] + public void Permission_JsonRoundTrip_ShouldPreserveValue() + { + var original = Permission.From("users.read"); + + var json = JsonSerializer.Serialize(original); + + var restored = JsonSerializer.Deserialize(json); + + restored.Should().Be(original); + restored.IsValid.Should().BeTrue(); + } + // ========================================================= // Fixture // ========================================================= From 2ed760290e2cbf9007f9e6ca75751ac827bc57af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 22:22:43 +0300 Subject: [PATCH 19/26] fix(client): improve HTTP result mapping and problem details handling --- .../UAuthExceptionHandlingExtensions.cs | 8 +- .../Infrastructure/UAuthResultMapper.cs | 56 +++++++++- .../Client/UAuthResultMapperTests.cs | 105 ++++++++++++++++++ 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index f2c6a3d7..9ba89be2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -27,20 +27,22 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) { + var status = MapStatusCode(ex); + var problem = new ProblemDetails { Title = ex.Title, Detail = ex.Code, - Status = MapStatusCode(ex), + Status = status, Type = $"{ex.TypePrefix}/{ex.Code}" }; problem.Extensions["traceId"] = context.TraceIdentifier; - context.Response.StatusCode = problem.Status ?? 500; + context.Response.StatusCode = status; context.Response.ContentType = "application/problem+json"; - return context.Response.WriteAsJsonAsync(problem); + return context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json"); } private static int MapStatusCode(UAuthRuntimeException ex) => diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 5b7ee29e..da59de93 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -16,11 +16,14 @@ public static UAuthResult FromJson(UAuthTransportResult raw) if (!IsSuccessStatus(raw.Status)) { + var problem = CreateProblem(raw); + return new UAuthResult { IsSuccess = false, Status = raw.Status, - Problem = TryDeserializeProblem(raw) + Problem = problem, + TraceId = problem?.TraceId }; } @@ -55,14 +58,25 @@ public static UAuthResult From(UAuthTransportResult raw) { EnsureTransport(raw); + if (IsSuccessStatus(raw.Status)) + { + return new UAuthResult + { + IsSuccess = true, + Status = raw.Status + }; + } + + var problem = CreateProblem(raw); + return new UAuthResult { - IsSuccess = IsSuccessStatus(raw.Status), + IsSuccess = false, Status = raw.Status, - Problem = IsSuccessStatus(raw.Status) ? null : TryDeserializeProblem(raw) + Problem = problem, + TraceId = problem?.TraceId }; } - //public static UAuthResult From(UAuthTransportResult raw) => FromJson(raw); private static bool IsSuccessStatus(int status) => status is >= 200 and < 300; @@ -78,14 +92,44 @@ private static void EnsureTransport(UAuthTransportResult raw) throw new UAuthTransportException($"Server error {raw.Status}", (HttpStatusCode)raw.Status); } + private static UAuthProblem? CreateProblem(UAuthTransportResult raw) + { + var parsed = TryDeserializeProblem(raw); + + if (parsed is null) + return null; + + return new UAuthProblem + { + Type = parsed?.Type, + Title = string.IsNullOrWhiteSpace(parsed?.Title) ? "HTTP request failed" : parsed.Title, + Detail = parsed?.Detail, + Status = raw.Status, + TraceId = parsed?.TraceId, + Extensions = parsed?.Extensions + }; + } + private static UAuthProblem? TryDeserializeProblem(UAuthTransportResult raw) { - if (raw.Body is null) + if (raw.Body is null || raw.Body.Value.ValueKind != JsonValueKind.Object) + { return null; + } try { - return raw.Body.Value.Deserialize(_jsonOptions); + var problem = raw.Body.Value.Deserialize(_jsonOptions); + + if (problem is null) + return null; + + if (string.IsNullOrWhiteSpace(problem.Type) && string.IsNullOrWhiteSpace(problem.Title) && string.IsNullOrWhiteSpace(problem.Detail)) + { + return null; + } + + return problem; } catch (JsonException) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs index 6e7c8c84..475ab82e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs @@ -215,6 +215,111 @@ public void From_ShouldRejectInvalidHttpStatus() act.Should().Throw(); } + [Fact] + public void FromJson_WhenProblemStatusDiffers_UsesHttpStatus() + { + var raw = new UAuthTransportResult + { + Status = 400, + Body = JsonSerializer.SerializeToElement(new + { + status = 200, + title = "Validation failed", + traceId = "trace-123" + }) + }; + + var result = UAuthResultMapper.FromJson(raw); + + Assert.False(result.IsSuccess); + Assert.Equal(400, result.Status); + Assert.NotNull(result.Problem); + Assert.Equal(400, result.Problem.Status); + Assert.Equal("trace-123", result.TraceId); + Assert.Equal("trace-123", result.Problem.TraceId); + } + + [Fact] + public void FromJson_WhenErrorBodyIsMissing_ReturnsNullProblem() + { + var raw = new UAuthTransportResult + { + Status = 403 + }; + + var result = UAuthResultMapper.FromJson(raw); + + Assert.False(result.IsSuccess); + Assert.Equal(403, result.Status); + Assert.Null(result.Problem); + } + + [Fact] + public void From_WhenErrorBodyIsInvalid_ReturnsNullProblem() + { + var raw = new UAuthTransportResult + { + Status = 400, + Body = JsonSerializer.SerializeToElement("Invalid request") + }; + + var result = UAuthResultMapper.From(raw); + + Assert.False(result.IsSuccess); + Assert.Equal(400, result.Status); + Assert.Null(result.Problem); + } + + [Fact] + public void From_WhenSuccessfulWithoutBody_ReturnsSuccess() + { + var raw = new UAuthTransportResult + { + Status = 204 + }; + + var result = UAuthResultMapper.From(raw); + + Assert.True(result.IsSuccess); + Assert.Equal(204, result.Status); + Assert.Null(result.Problem); + } + + [Fact] + public void FromJson_WhenSuccessfulWithoutBody_ThrowsProtocolException() + { + var raw = new UAuthTransportResult + { + Status = 200 + }; + + Assert.Throws( + () => UAuthResultMapper.FromJson(raw)); + } + + [Fact] + public void FromJson_Should_Use_Http_Status_In_Problem() + { + var raw = new UAuthTransportResult + { + Status = 403, + Body = JsonSerializer.SerializeToElement(new + { + title = "Forbidden", + status = 200, + traceId = "trace-123" + }) + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeFalse(); + result.Status.Should().Be(403); + result.Problem.Should().NotBeNull(); + result.Problem!.Status.Should().Be(403); + result.TraceId.Should().Be("trace-123"); + } + private sealed class TestDto { From 93529784ab10d17e24ba2211bf4a67783104cfd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 22:39:14 +0300 Subject: [PATCH 20/26] refactor(api): remove dormant contracts and clarify user integration architecture --- .../Domain/User/IAuthSubject.cs | 20 ------------ .../Domain/User/UserRuntimeRecord.cs | 18 +++++++++++ .../Requests/AuthorizationRequest.cs | 24 -------------- .../Request/ResetPasswordRequest.cs | 12 ------- .../ICredentialAuthenticationService.cs | 22 ++++++------- .../Requests/ChangeUserIdentifierRequest.cs | 8 ----- .../Requests/RegisterUserRequest.cs | 31 ------------------- .../Responses/GetUserIdentifiersResult.cs | 6 ---- .../Responses/IdentifierChangeResult.cs | 11 ------- .../Responses/IdentifierDeleteResult.cs | 10 ------ .../Responses/IdentifierVerificationResult.cs | 11 ------- .../Abstractions/IUser.cs | 7 ----- .../Abstractions/IUserSecurityEvents.cs | 16 +++++----- 13 files changed, 37 insertions(+), 159 deletions(-) delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs delete mode 100644 src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs delete mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs deleted file mode 100644 index 9099cbf6..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; - -/// -/// Represents the minimal user abstraction required by UltimateAuth. -/// Includes the unique user identifier and an optional set of claims that -/// may be used during authentication or session creation. -/// -public interface IAuthSubject -{ - /// - /// Gets the unique identifier of the user. - /// - TUserId UserId { get; } - - /// - /// Gets an optional collection of user claims that may be used to construct - /// session-level claim snapshots. Implementations may return null if no claims are available. - /// - IReadOnlyDictionary? Claims { get; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs index 8629eb13..369b85c5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -1,5 +1,23 @@ namespace CodeBeam.UltimateAuth.Core.Domain; +// TODO (User Management Expansion): +// UltimateAuth must not require host applications to inherit from framework +// user types or implement interfaces on their existing user entities. +// +// Host applications should integrate their user models through dedicated +// adapter/provider interfaces registered via dependency injection. +// +// Future User Management Expansion work should define and document these +// integration contracts, including user resolution, runtime state mapping, +// lifecycle integration, and claims provisioning. +// +// Keep host user entities independent from UltimateAuth contracts. +// Do not introduce a mandatory IUser or IAuthSubject dependency. + +/// +/// Represents the minimal user state required by UltimateAuth at runtime. +/// This is a framework-owned snapshot, not a host application user entity. +/// public sealed record UserRuntimeRecord { public UserKey UserKey { get; init; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs deleted file mode 100644 index 4e3f0f33..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record AuthorizationRequest -{ - /// - /// Logical operation being requested (e.g. "orders.read"). - /// - public required string Operation { get; init; } - - /// - /// Optional resource identifier (row, entity, aggregate, etc). - /// - public string? Resource { get; init; } - - /// - /// Optional resource identifier. - /// - public string? ResourceId { get; init; } - - /// - /// Optional contextual attributes for fine-grained access decisions. - /// - public IReadOnlyDictionary? Attributes { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs deleted file mode 100644 index 710efa1a..00000000 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CodeBeam.UltimateAuth.Credentials.Contracts; - -public sealed record ResetPasswordRequest -{ - public Guid Id { get; init; } - public required string NewPassword { get; init; } - - /// - /// Optional reset token or verification code. - /// - public string? Token { get; init; } -} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs index b4ab7ec1..3a04b5d1 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs @@ -1,18 +1,18 @@ -using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Credentials.Contracts; +//using CodeBeam.UltimateAuth.Core.Contracts; +//using CodeBeam.UltimateAuth.Credentials.Contracts; -namespace CodeBeam.UltimateAuth.Credentials.Reference; +//namespace CodeBeam.UltimateAuth.Credentials.Reference; -/// -/// Orchestrates an authentication attempt against a credential type. -/// Responsible for applying lockout policy by mutating the credential aggregate -/// and persisting it. -/// -public interface ICredentialAuthenticationService -{ +///// +///// Orchestrates an authentication attempt against a credential type. +///// Responsible for applying lockout policy by mutating the credential aggregate +///// and persisting it. +///// +//public interface ICredentialAuthenticationService +//{ //Task AuthenticateAsync( // AccessContext context, // CredentialType type, // CredentialAuthenticationRequest request, // CancellationToken ct = default); -} \ No newline at end of file +//} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs deleted file mode 100644 index d417d6a8..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record ChangeUserIdentifierRequest -{ - public required UserIdentifierType Type { get; init; } - public required string NewValue { get; init; } - public string? Reason { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs deleted file mode 100644 index cbc4924f..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; - -namespace CodeBeam.UltimateAuth.Users.Contracts; - -/// -/// Request to register a new user with credentials. -/// -public sealed record RegisterUserRequest -{ - /// - /// Unique user identifier (username, email, or external id). - /// Interpretation is application-specific. - /// - public required string Identifier { get; init; } - - /// - /// Plain-text password. - /// Will be hashed by the configured password hasher. - /// - public required string Password { get; init; } - - /// - /// Optional tenant identifier. - /// - public TenantKey Tenant { get; init; } - - /// - /// Optional initial claims or metadata. - /// - public IReadOnlyDictionary? Metadata { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs deleted file mode 100644 index 4a78263a..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record GetUserIdentifiersResult -{ - public required IReadOnlyCollection Identifiers { get; init; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs deleted file mode 100644 index 216008c0..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record IdentifierChangeResult -{ - public bool IsSuccess { get; init; } - public string? FailureReason { get; init; } - - public static IdentifierChangeResult Success() => new() { IsSuccess = true }; - - public static IdentifierChangeResult Failed(string reason) => new() { IsSuccess = false, FailureReason = reason }; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs deleted file mode 100644 index e5db8797..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record IdentifierDeleteResult -{ - public bool IsSuccess { get; init; } - public string? FailureReason { get; init; } - - public static IdentifierDeleteResult Success() => new() { IsSuccess = true }; - public static IdentifierDeleteResult Fail(string reason) => new() { IsSuccess = false, FailureReason = reason }; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs deleted file mode 100644 index a9729d21..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users.Contracts; - -public sealed record IdentifierVerificationResult -{ - public bool IsSuccess { get; init; } - public string? FailureReason { get; init; } - - public static IdentifierVerificationResult Success() => new() { IsSuccess = true }; - - public static IdentifierVerificationResult Failed(string reason) => new() { IsSuccess = false, FailureReason = reason }; -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs deleted file mode 100644 index b3cda978..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CodeBeam.UltimateAuth.Users; - -public interface IUser -{ - TUserId UserId { get; } - bool IsActive { get; } -} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs index 01dea3d1..74a4a849 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -1,10 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Users; +//namespace CodeBeam.UltimateAuth.Users; -public interface IUserSecurityEvents -{ - Task OnUserActivatedAsync(UserKey userKey); - Task OnUserDeactivatedAsync(UserKey userKey); - Task OnSecurityInvalidatedAsync(UserKey userKey); -} +//public interface IUserSecurityEvents +//{ +// Task OnUserActivatedAsync(UserKey userKey); +// Task OnUserDeactivatedAsync(UserKey userKey); +// Task OnSecurityInvalidatedAsync(UserKey userKey); +//} From fd08017d88d839da25802f8f1dea3bb78a2e1726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 22:52:13 +0300 Subject: [PATCH 21/26] fix(server): correct ITokenIssuer abstraction namespace --- src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs | 3 +-- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Flows/Login/LoginOrchestrator.cs | 2 +- .../Infrastructure/Issuers/UAuthTokenIssuer.cs | 3 +-- .../Services/RefreshTokenRotationService.cs | 2 +- .../Server/RefreshTokenRotationServiceTests.cs | 3 +-- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 46b3f351..106a7ec7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -1,8 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Contracts; -namespace CodeBeam.UltimateAuth.Server.Abstactions; +namespace CodeBeam.UltimateAuth.Server.Abstractions; /// /// Issues access and refresh tokens according to the active auth mode. diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index ab9cca3f..d3566994 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -13,7 +13,6 @@ using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; using CodeBeam.UltimateAuth.Policies.Registry; -using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 4e412432..a8b8f377 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -6,7 +6,7 @@ using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Credentials; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 323055e6..1649494c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -3,9 +3,8 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; -using CodeBeam.UltimateAuth.Server.Contracts; namespace CodeBeam.UltimateAuth.Server.Infrastructure; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 047a43d7..fbdc931b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -4,7 +4,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Services; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 51f24ac0..571095f9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -2,10 +2,9 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; From 8be17e1f92c54d4ac54dab1e8cac1a731f423775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 23:32:56 +0300 Subject: [PATCH 22/26] Update Architecture and AI-Guardrails --- .ultimateauth/ai-guardrails.md | 243 +++++++++++++++++++++++----- .ultimateauth/architecture.md | 283 ++++++++++++++++++++------------- 2 files changed, 371 insertions(+), 155 deletions(-) diff --git a/.ultimateauth/ai-guardrails.md b/.ultimateauth/ai-guardrails.md index 3337aade..10e5785a 100644 --- a/.ultimateauth/ai-guardrails.md +++ b/.ultimateauth/ai-guardrails.md @@ -13,17 +13,31 @@ These rules are NON-NEGOTIABLE. --- -## 2. Forbidden Refactors +## 2. Protected Architectural Boundaries + +AI tools MUST NOT independently introduce changes that alter established architectural or security boundaries. + +The following components and mechanisms are considered security-critical: + +- AuthFlowContext and its creation lifecycle. +- AccessContext and its derivation rules. +- Authentication and authorization separation. +- Security-sensitive orchestration and authority evaluation. +- Session and token validation mechanisms. +- Cross-domain identity and security boundaries. AI tools MUST NOT: -- Refactor or replace AuthFlowContext. -- Refactor or replace AccessContext. -- Change how AuthFlowContext or AccessContext is created. -- Introduce lazy, implicit, or on-demand context creation. -- Merge authentication and authorization logic. -- Move security logic into stores or domain models. +- Replace or redesign these mechanisms without explicit maintainer approval. +- Introduce lazy, implicit, or uncontrolled security context creation. +- Merge authentication and authorization responsibilities. +- Move authorization decisions into stores or domain models. - Introduce client-side authority over identity or access. +- Remove or bypass established security validation mechanisms. + +Internal refactoring MAY be performed when explicitly authorized, provided all applicable architectural invariants remain preserved. + +A change that modifies a security boundary MUST be treated as an architectural change, not as a routine refactor. --- @@ -31,12 +45,17 @@ AI tools MUST NOT: AI tools MUST NOT: -- Mutate AuthFlowContext after creation. -- Create more than one AuthFlowContext per request. -- Create AccessContext independently of AuthFlowContext. -- Bypass context usage in application or domain code. +- Arbitrarily mutate an established AuthFlowContext. +- Replace or reconstruct security contexts outside designated framework mechanisms. +- Create AccessContext independently of trusted authentication state. +- Allow application or domain code to override established authentication or authorization context. +- Introduce hidden, implicit, or uncontrolled security context creation. + +Within an HTTP authentication pipeline, the effective authentication context MUST remain stable throughout its execution scope. -Context objects define security boundaries. +Non-HTTP execution environments MAY use explicitly defined context creation mechanisms, provided equivalent security guarantees are preserved. + +Any proposed change to context ownership, creation, or derivation MUST receive explicit maintainer approval. --- @@ -44,31 +63,47 @@ Context objects define security boundaries. AI tools MUST NOT: -- Allow security-relevant operations to bypass orchestrators. -- Call stores directly for security-sensitive operations. -- Embed policy or authorization logic in services or stores. -- Bypass authority evaluation for any operation that affects: - - authentication state - - authorization decisions - - session validity - - credentials - - user security state +- Bypass required orchestrators in security-sensitive workflows. +- Bypass applicable authority decisions. +- Embed authorization policies or permission decisions in stores. +- Introduce direct persistence operations that circumvent required security checks. +- Move cross-domain security coordination into unrelated components. + +AI tools MUST preserve the designated execution path for each security-sensitive operation. -All such operations MUST pass through an orchestrator and an authority component. +- Operations requiring authorization decisions MUST use the applicable authority. +- Complex security-sensitive workflows MUST use their designated orchestrators. +- Cryptographic verification, protocol validation, and deterministic security checks MAY be performed by dedicated validators or security primitives. +- Stores MUST remain persistence-focused and MUST NOT independently make authorization decisions. + +AI tools MUST NOT introduce unnecessary authority or orchestration dependencies for operations that do not require them. --- ## 5. Session and Token Rules +AI tools MUST preserve the authentication, validation, and revocation semantics defined for each authentication mode. + AI tools MUST NOT: -- Treat tokens as the primary source of identity. -- Introduce token-only authentication flows. -- Bypass server-side session validation. -- Weaken revocation or invalidation guarantees. -- Introduce eventual or best-effort revocation semantics. +- Treat unvalidated client-provided credentials as trusted identity. +- Introduce token validation paths that bypass the requirements of the effective authentication mode. +- Remove required session validation from stateful authentication modes. +- Introduce mandatory request-time session lookups into stateless modes without explicit architectural approval. +- Weaken session or token revocation guarantees without explicit maintainer approval. +- Claim immediate revocation when the implementation cannot enforce it. +- Change token issuance or validation semantics as an incidental refactor. + +The following mode-specific boundaries MUST be preserved: + +- **PureOpaque:** Server-side session authentication. +- **Hybrid:** Stateful authentication with opaque access tokens, refresh tokens, and server-side session validation. +- **SemiHybrid:** Stateless JWT access-token validation with server-side session lifecycle management. +- **PureJwt:** Stateless JWT authentication without mandatory session persistence. -Sessions are server-authoritative. +AI tools MUST NOT change the authentication mode contract without explicit maintainer approval. + +Revocation behavior MUST remain consistent with the documented guarantees of the effective authentication mode. --- @@ -77,11 +112,20 @@ Sessions are server-authoritative. AI tools MUST NOT: - Merge UserLifecycle, UserProfile, or UserIdentifier domains. -- Introduce cross-domain state sharing. -- Allow domains to modify each other directly. -- Treat UserKey as a domain entity. +- Introduce unauthorized cross-domain mutable state sharing. +- Allow domains to modify each other's state directly. +- Treat UserKey as a domain entity or persistence entity. +- Introduce mandatory UltimateAuth inheritance requirements for host user entities. +- Require host application user models to implement UltimateAuth-specific interfaces. +- Couple framework-owned runtime identity records directly to host persistence entities. + +UserKey MUST remain an opaque cross-domain identity anchor. + +Host user models MUST remain independent of UltimateAuth framework types. -UserKey is an opaque cross-domain identity anchor. +Integration with host user entities MUST occur through explicit adapters, providers, or controlled boundary contracts. + +Cross-domain security operations MUST be coordinated through designated services and orchestrators. --- @@ -89,10 +133,16 @@ UserKey is an opaque cross-domain identity anchor. AI tools MUST NOT: -- Introduce runtime-specific authentication semantics. -- Allow clients or SDKs to create identity. -- Move authorization decisions to the client. -- Remove or weaken PKCE requirements for public clients. +- Introduce runtime-specific security authority. +- Allow client SDKs to establish trusted identity without server-side validation. +- Move authorization decisions to untrusted clients. +- Weaken PKCE requirements for authorization-code flows involving public clients. +- Introduce inconsistent security guarantees for the same authentication mode across different runtimes. +- Duplicate core authentication logic solely to accommodate a client platform. + +Client runtimes MAY use different transport mechanisms, credential delivery strategies, and authentication flow adaptations. + +Runtime-specific adaptations MUST preserve the security guarantees of the effective authentication mode. Client SDKs are adapters, not security authorities. @@ -102,17 +152,122 @@ Client SDKs are adapters, not security authorities. AI tools MUST NOT: -- Introduce extension points that bypass security invariants. -- Allow overrides to weaken orchestration or authority rules. -- Alter server-authoritative behavior for convenience. +- Introduce extension points that bypass mandatory security controls. +- Allow overrides to circumvent applicable authority decisions. +- Allow plugins to weaken authentication mode guarantees. +- Introduce hidden security behavior through extension mechanisms. +- Alter server-authoritative security boundaries for convenience or performance. +- Couple host application domain models directly to framework-owned persistence or runtime models. + +Extensions MUST preserve the security invariants applicable to the effective authentication mode. -Extensibility MUST preserve all security guarantees. +New extension points affecting authentication, authorization, credential handling, or session lifecycle MUST receive explicit maintainer approval before implementation. --- -## 9. Final Enforcement Rule +## 9. Change Authorization and Review + +AI tools MUST classify proposed changes before implementation. + +### 9.1 Routine Changes + +Routine changes MAY proceed within the explicitly authorized task scope when they preserve all applicable architectural and security invariants. + +Examples include: + +- Documentation corrections. +- Non-behavioral code cleanup. +- Focused bug fixes that preserve established contracts. +- Additional tests for existing behavior. + +### 9.2 Security-Critical Changes + +The following changes REQUIRE explicit maintainer approval: + +- Authentication mode behavior changes. +- Token issuance, validation, or revocation changes. +- Security context creation or lifecycle changes. +- Authorization or authority evaluation changes. +- Session lifecycle or persistence semantics changes. +- Credential handling or verification changes. +- New security-sensitive extension points. +- Changes to public security contracts. + +Approval for one change MUST NOT be interpreted as approval for unrelated changes. + +### 9.3 Architectural Conflicts + +If a requested change conflicts with `.ultimateauth/architecture.md`, AI tools MUST: + +1. Identify the conflicting architectural rule. +2. Explain why the proposed change conflicts with that rule. +3. Present a compliant alternative when possible. +4. Request explicit maintainer review if an architectural amendment is necessary. +5. Refrain from implementing the conflicting change until the canonical architecture has been explicitly updated. + +AI tools MUST NOT silently modify architectural rules to justify an implementation. + +--- + +## 10. Verification Requirements + +AI tools MUST verify that their changes preserve applicable architectural and security guarantees. + +For security-relevant changes, AI tools MUST: + +- Identify affected authentication modes and client profiles. +- Review relevant issuance, validation, authorization, and revocation paths. +- Preserve existing negative security tests. +- Add or update focused regression tests when behavior changes. +- Check for unintended changes to public contracts. +- Report any validation or testing that could not be completed. + +AI tools MUST NOT: + +- Remove or weaken security tests merely to make a change pass. +- Modify unrelated security behavior without explicit authorization. +- Claim that a change is secure solely because the project builds successfully. +- Claim tests passed when they were not executed. +- Conceal unresolved security concerns or architectural conflicts. + +If the required verification cannot be completed, the AI tool MUST clearly report the limitation. + +--- + +## 11. Final Enforcement Rule + +If an AI tool cannot determine whether a proposed change preserves the applicable architectural and security invariants, it MUST NOT proceed with the uncertain change without explicit maintainer review. + +AI tools MUST distinguish between: + +- A confirmed architectural violation. +- A potential security or architectural concern. +- An implementation detail that does not alter architectural guarantees. + +AI tools MUST NOT treat uncertainty as evidence of a confirmed vulnerability. + +Explicit maintainer approval is required for architectural changes, but approval alone does not override the canonical architecture. + +When an architectural change is necessary, `.ultimateauth/architecture.md` MUST be intentionally reviewed and updated before implementing the conflicting behavior. + +Security correctness, architectural consistency, and verifiable behavior take precedence over convenience, performance, or refactoring elegance. + +--- + +## 12. Scope Discipline + +AI tools MUST remain within the scope of the explicitly authorized task. + +AI tools MUST NOT: + +- Expand a focused bug fix into an unrelated architectural refactor. +- Implement roadmap features without explicit authorization. +- Change authentication mode semantics while fixing unrelated issues. +- Introduce new abstractions solely for speculative future requirements. +- Remove public contracts without reviewing their intended purpose and compatibility impact. +- Treat TODO comments as authorization to implement unfinished features. +- Modify unrelated files merely for stylistic consistency. -If an AI tool is uncertain whether a change violates these guardrails, the change MUST NOT be made. +When an unrelated issue is discovered, AI tools SHOULD report it separately rather than modifying it without authorization. -Security correctness takes precedence over convenience, -performance, or refactoring elegance. +Deferred work MUST remain deferred unless explicitly reopened by the maintainer. \ No newline at end of file diff --git a/.ultimateauth/architecture.md b/.ultimateauth/architecture.md index 393e820d..4a8ecc02 100644 --- a/.ultimateauth/architecture.md +++ b/.ultimateauth/architecture.md @@ -76,44 +76,55 @@ This document is NOT intended as an onboarding guide or end-user documentation. ## 2. Core Authentication Model -UltimateAuth defines authentication as a session-centered, server-authoritative process. -Authentication determines *who* the current actor is and establishes a stable identity context for the duration of a request or session. Authorization decisions are explicitly out of scope for this model and are handled separately. +UltimateAuth defines authentication as a server-authoritative process that establishes and validates the identity of the current actor. + +Authentication determines *who* the current actor is and establishes a trustworthy identity context for the duration of an authenticated operation. + +Authentication state MAY be represented through server-side sessions, cryptographically verifiable tokens, or a combination of both, depending on the effective authentication mode. + +Authorization decisions are explicitly separate from authentication and are handled through dedicated authorization mechanisms. --- -### 2.1 Session as the Source of Truth +### 2.1 Server Authority and Authentication State -In UltimateAuth, sessions are the primary and authoritative representation of authentication state. +UltimateAuth treats the server as the authoritative source of authentication rules and security decisions. -- A session represents an authenticated identity. -- Tokens, cookies, and other credentials are transport mechanisms, not identity sources. -- Authentication state MUST be verifiable on the server using session-backed data. +- Authentication authority MUST remain under server control. +- Client-provided identity information MUST NOT be trusted without validation. +- Authentication credentials MUST be validated according to the effective authentication mode. +- Session-backed modes MUST preserve the authority of server-side session state. +- Stateless modes MAY establish authenticated identity through cryptographically verifiable tokens without request-time session lookup. -Any authentication model that treats tokens as the primary source of identity is explicitly rejected. +Tokens, cookies, and other credentials represent evidence of authentication. Their presence alone MUST NOT establish authenticated identity without the validation required by the effective authentication mode. + +No authentication mode may delegate security authority to an untrusted client. --- ### 2.2 Authentication Modes -UltimateAuth supports multiple authentication modes to address different deployment and runtime requirements. +UltimateAuth defines multiple authentication modes to support different security, lifecycle, and runtime requirements. The following authentication modes are defined: - **PureOpaque** - Authentication is fully session-based. No client-readable identity tokens (except session id) are exposed. + Fully stateful, session-based authentication. The server-side session is the primary authentication credential and source of authentication state. No separate access or refresh token is required. - **Hybrid** - Session-backed authentication with client-readable tokens used as a performance optimization. + Stateful authentication combining server-side sessions, opaque access tokens, and refresh tokens. Authentication remains session-authoritative, and authenticated requests MUST enforce session validity. - **SemiHybrid** - Session-backed authentication with limited client-side identity representation. + Authentication combining stateless JWT access-token validation with server-side session lifecycle management. Sessions support operations such as refresh, logout, revocation, and device management. Normal JWT validation does not require a request-time session lookup. - **PureJwt** - Token-centric authentication with server-side validation, used only when session-backed models are not feasible. + Stateless JWT-based authentication without mandatory server-side session persistence or request-time session lookup. Token validity is established through cryptographic verification and applicable token validation rules. + +Authentication modes define different validation and lifecycle strategies, not merely different credential transport formats. -Authentication modes define *how authentication state is represented and transported*, not *how identity is verified*. +Each mode MUST preserve the framework's common authentication and authorization boundaries while enforcing its own explicitly defined security guarantees. -The selected authentication mode MUST NOT alter core authentication semantics. +Defining an authentication mode does not imply that its implementation is complete or available in every release. Supported modes MUST be documented separately. --- @@ -158,15 +169,19 @@ Authorization systems MUST rely on authenticated identity provided by the authen ### 2.6 Extensibility Without Semantic Drift -The core authentication model is extensible through plugin domains and override points. +The core authentication model is extensible through plugin domains and explicit extension points. -Extensibility MUST preserve the semantic guarantees defined in this section. +Extensibility MUST preserve the security guarantees applicable to the effective authentication mode. -Custom implementations, alternative storage mechanisms, or runtime-specific optimizations MUST NOT change: +Custom implementations, alternative storage mechanisms, and runtime-specific optimizations MUST NOT change: -- The session-first nature of authentication. -- The server-authoritative identity model. +- The server-authoritative nature of authentication. - The separation between authentication and authorization. +- The integrity of authenticated identity and security contexts. +- The validation and revocation guarantees defined for the selected authentication mode. +- The requirement that security-sensitive decisions remain under trusted server-side control. + +Extensions MUST NOT silently weaken or redefine the security semantics of an authentication mode. ## 3. Request Pipeline & Context Model @@ -177,16 +192,16 @@ This pipeline is responsible for producing immutable context objects that repres ### 3.1 AuthFlowContext -AuthFlowContext represents the complete authentication flow state of a request. +AuthFlowContext represents the effective authentication flow context established for an authentication operation. -- AuthFlowContext is created exactly once per request. -- AuthFlowContext is request-scoped. -- AuthFlowContext is immutable after creation. -- AuthFlowContext is the single source of truth for authentication-related data during request processing. +- Within an HTTP authentication pipeline, AuthFlowContext MUST be established through the designated context creation mechanism. +- Once established, the effective AuthFlowContext MUST remain stable throughout the operation. +- AuthFlowContext MUST NOT be arbitrarily mutated, replaced, or reconstructed by application or domain code. +- Non-HTTP execution environments MAY use explicitly defined context creation mechanisms, provided equivalent security guarantees are preserved. -AuthFlowContext encapsulates all information required to evaluate authentication state, including session data, client characteristics, and runtime-specific signals. +AuthFlowContext encapsulates the authentication-related information required by the operation, including effective authentication mode, client characteristics, tenant context, and applicable runtime signals. -AuthFlowContext MUST NOT be modified, replaced, or reconstructed after initial creation. +Context creation and ownership MUST remain under framework-controlled boundaries. --- @@ -205,13 +220,14 @@ Authentication decisions MUST be resolved before AccessContext is created. ### 3.3 Context Creation Boundaries -Context creation is a controlled operation within the request pipeline. +Context creation is a controlled operation within the authentication and authorization pipeline. -- AuthFlowContext creation is part of the authentication pipeline and occurs before application logic executes. -- AccessContext creation is a deterministic transformation of AuthFlowContext. -- Context objects MUST NOT be created lazily or on demand within application or domain code. +- Authentication context creation MUST occur through designated framework mechanisms. +- AccessContext MUST be derived from trusted authentication state and the applicable authorization request context. +- Context creation MUST NOT be delegated to arbitrary application or domain code. +- Services and stores MUST NOT independently reconstruct or override established security contexts. -Application code, services, and stores MUST treat AuthFlowContext and AccessContext as read-only inputs. +Application code MUST treat established security contexts as trusted, read-only inputs. --- @@ -221,7 +237,7 @@ Authentication evaluation in UltimateAuth is deterministic and request-based. - Each request independently derives its authentication and authorization context. - No implicit cross-request authentication state is allowed. -- Retried, concurrent, or replayed requests MUST produce equivalent authentication results given the same inputs. +- Authentication evaluation MUST be consistent for equivalent inputs and equivalent authoritative security state. State transitions, replay protection, token rotation, and revocation MAY legitimately produce different outcomes for otherwise identical requests. Deterministic evaluation ensures predictable behavior across distributed systems and asynchronous execution. @@ -241,15 +257,15 @@ The exact integration mechanism (e.g. middleware, endpoint filters, or framework ### 3.6 Architectural Invariants -The following invariants MUST hold for all implementations: +The following invariants MUST hold: -- Exactly one AuthFlowContext exists per request. -- AuthFlowContext is immutable after creation. -- AccessContext is derived from AuthFlowContext and never the inverse. -- Application and domain code MUST NOT influence authentication context creation. -- Context objects define security boundaries and MUST NOT be bypassed. +- The effective authentication context remains stable throughout its execution scope. +- AccessContext is derived from trusted authentication state, never the inverse. +- Security contexts MUST NOT be arbitrarily modified or replaced by application code. +- Context creation MUST occur through explicit, controlled boundaries. +- Context integrity MUST be preserved across supported execution environments. -Any implementation that violates these invariants is considered architecturally incorrect. +Any implementation that bypasses these boundaries is architecturally incorrect. ## 4. Domain Boundaries @@ -319,7 +335,23 @@ The internal representation of UserKey MUST remain flexible and replaceable with --- -### 4.5 Domain Independence Guarantees +### 4.5 Host User Model Independence + +UltimateAuth MUST remain independent of host application user entity models. + +- Host applications MUST NOT be required to inherit from framework-owned user entities. +- Host user entities MUST NOT be required to implement UltimateAuth-specific interfaces. +- Integration with host user models MUST occur through explicit adapters, providers, or boundary contracts. +- Framework-owned runtime records and identity snapshots MUST remain independent of host persistence entities. +- Mapping between host user identities and UltimateAuth UserKey values MUST occur at controlled integration boundaries. + +Reference implementations MAY provide ready-to-use adapters or providers, but MUST NOT impose a mandatory host user model. + +UltimateAuth MUST preserve its authentication, authorization, and domain invariants regardless of the host application's user entity structure. + +--- + +### 4.6 Domain Independence Guarantees The following guarantees MUST hold: @@ -387,23 +419,30 @@ New orchestrators MAY be introduced as the system evolves. ### 5.4 Authority Components -Authority components are responsible for making security and authorization decisions. +Authority components are responsible for security and authorization decisions within their explicitly defined responsibility boundaries. + +- Authorities evaluate applicable security policies and permissions. +- Authorities determine whether protected operations are permitted. +- Authorities MUST remain independent of persistence implementation details. +- Authority decisions MUST NOT be bypassed by services or orchestrators. -- Authorities evaluate policies and permissions. -- Authorities validate whether an operation is allowed. -- Authorities are the final decision point for security-sensitive actions. +Not every security-related validation requires an Authority component. -Authority logic MUST NOT be embedded in services, stores or domain models. +Cryptographic verification, token parsing, protocol validation, and other deterministic validation operations MAY be performed by dedicated validators or security primitives. --- -### 5.5 Mandatory Orchestration Rule +### 5.5 Mandatory Security Coordination Rule -All security-relevant operations MUST pass through an orchestrator and an authority. +Security-sensitive operations MUST follow their designated framework-controlled execution paths. -No operation that affects authentication state, authorization decisions, session validity, credentials, or user security state may be executed without explicit orchestration and authority evaluation. +- Operations requiring authorization decisions MUST invoke the applicable authority. +- Complex security-sensitive workflows MUST use their designated orchestrators. +- Services MUST NOT bypass required authorization or security checks. +- Stores MUST NOT independently make authorization decisions. +- Cryptographic and protocol validation MAY be performed by dedicated components without introducing unnecessary orchestration layers. -Bypassing orchestrators or authorities is considered a critical architectural violation. +No implementation may bypass a security boundary required by the operation being performed. --- @@ -412,30 +451,35 @@ Bypassing orchestrators or authorities is considered a critical architectural vi The following guarantees MUST hold: - Stores are never policy-aware. -- Services never perform security decisions directly. +- Services MUST NOT independently override required authority decisions. - Orchestrators always coordinate security-sensitive flows. - Authorities are the single source of truth for authorization decisions. -- No execution path may bypass orchestrators and authorities. +- No execution path may bypass the security controls required by its designated execution model. Violations of these guarantees compromise system security and are not permitted. ## 6. Session Architecture -UltimateAuth defines sessions as the primary and authoritative representation of authenticated identity. +UltimateAuth provides a server-controlled session architecture for authentication modes that require stateful identity management. + +Sessions establish authentication continuity, lifecycle management, revocation capabilities, and server-side control over authenticated identity. -Sessions establish continuity, revocation guarantees, and server-side control over authentication state. +The role of sessions depends on the effective authentication mode. + +Session-backed modes MUST enforce their documented session validation guarantees. Stateless modes MUST NOT be required to introduce server-side session persistence solely to conform to the session architecture. --- ### 6.1 Session as an Authentication Primitive -A session represents an authenticated identity and its associated security state. +A session represents server-managed authenticated identity state and its associated security lifecycle. - Sessions are server-owned and server-validated. -- Sessions define authentication continuity across requests. -- Sessions are the authoritative source of authentication truth. +- Sessions provide authentication continuity and lifecycle control. +- In stateful authentication modes, sessions are authoritative for authentication validity. +- In modes using stateless access tokens, sessions MAY remain authoritative for lifecycle operations without participating in every access-token validation. -Tokens, cookies, or other client-held artifacts are transport mechanisms and MUST NOT be treated as identity sources. +Client-held credentials MUST be validated according to the effective authentication mode. --- @@ -454,25 +498,28 @@ Session composition enables controlled delegation, refresh flows, and security i ### 6.3 Session Validation and Resolution -Session validation is performed on every request that requires authentication. +Session validation is required whenever the effective authentication mode or operation depends on server-side session state. -- Session validity MUST be verified server-side. -- Session resolution MUST be deterministic. -- Cached or inferred session state is not permitted. +- Stateful authentication modes MUST validate session validity for authenticated access. +- Session resolution MUST follow explicit and deterministic rules. +- Stateless access-token validation MAY operate without request-time session lookup. +- Operations that require session state, including session refresh and session revocation, MUST validate the applicable session. -Session resolution MUST NOT depend solely on client-held data. +Session validation requirements MUST be explicitly defined for each authentication mode. --- ### 6.4 Revocation and Invalidation Semantics -Session revocation is a first-class security operation. +Revocation is a first-class security operation in UltimateAuth. -- Revoked sessions MUST be rejected immediately. -- Revocation MUST be enforceable across all authentication modes. -- Session invalidation MUST propagate deterministically. +- Stateful authentication modes MUST reject revoked sessions during subsequent authentication validation. +- Session-dependent operations MUST reject revoked or invalid sessions. +- Stateless access tokens MAY remain valid until expiration unless an additional revocation mechanism is explicitly supported. +- Revocation guarantees MUST be documented separately for each authentication mode. +- No implementation may claim immediate access-token revocation without an enforcement mechanism capable of providing that guarantee. -Eventual or best-effort revocation semantics are explicitly rejected. +Revocation behavior MUST be explicit, predictable, and consistent with the selected authentication mode. --- @@ -490,13 +537,14 @@ Refresh mechanisms MUST NOT bypass session validation or authority evaluation. ### 6.6 Relationship Between Sessions and Authentication Modes -Authentication modes define how session state is represented and transported, not how it is validated. +Authentication modes determine how authentication credentials are validated and how session state participates in authentication. -- All authentication modes (except PureJwt) rely on session-backed validation. -- Token-based representations MUST be verifiable against session state. -- Switching authentication modes MUST NOT change session semantics. +- **PureOpaque:** Session state is authoritative for authenticated access. +- **Hybrid:** Opaque access tokens are validated through server-controlled, session-backed authentication. +- **SemiHybrid:** JWT access tokens are validated without mandatory request-time session lookup; sessions govern applicable lifecycle operations. +- **PureJwt:** Authentication is based on stateless JWT validation without mandatory session persistence. -Session architecture remains consistent across all modes. +Switching authentication modes MAY change validation and revocation behavior, but MUST NOT weaken the common security boundaries of the framework. --- @@ -504,13 +552,14 @@ Session architecture remains consistent across all modes. The following invariants MUST hold: -- Sessions are the single source of authenticated identity. -- Session validation occurs server-side. -- Revocation is immediate and deterministic. -- Tokens never replace session authority. -- No authentication flow may bypass session validation. +- Authentication authority remains under server control. +- Stateful authentication modes MUST enforce session validity. +- Stateless authentication modes MUST validate token authenticity, integrity, expiration, and applicable security constraints. +- Session-dependent operations MUST NOT bypass required session validation. +- Revocation guarantees MUST match the actual enforcement capabilities of the selected authentication mode. +- Client-held credentials MUST NOT be trusted without appropriate validation. -Any implementation that violates these invariants is considered architecturally incorrect. +Any implementation violating these invariants is architecturally incorrect. ## 7. Client & Runtime Model @@ -572,13 +621,17 @@ No client runtime is permitted to cache or infer authentication authority outsid ### 7.5 Public Clients and PKCE Requirements -Public clients (including browser-based and mobile clients) are treated as untrusted environments. +Public clients, including browser-based and mobile applications, operate in environments that cannot be assumed to protect long-lived client authentication secrets. + +- Public clients MUST NOT be trusted to securely maintain confidential client credentials. +- Authorization-code flows involving public clients MUST use PKCE. +- PKCE validation MUST follow the supported protocol requirements and security invariants. +- Client-provided metadata MUST NOT independently establish trusted client identity. +- Authentication flows MUST account for potentially compromised or malicious clients. -- Public clients MUST NOT hold secrets. -- PKCE is REQUIRED for authorization flows involving public clients. -- Authentication flows MUST assume client compromise is possible. +User credentials, short-lived authorization artifacts, and PKCE code verifiers MUST NOT be confused with confidential client authentication secrets. -Security guarantees MUST be preserved even in the presence of malicious or compromised clients. +Security guarantees MUST remain enforceable at trusted server-side boundaries. --- @@ -625,15 +678,16 @@ No client runtime, SDK, or application code may assume authority over identity o --- -### 8.2 Session Authority Invariant +### 8.2 Authentication State Validation Invariant -Sessions are the single authoritative source of authenticated identity. +Authentication state MUST be validated according to the effective authentication mode. -- Authentication state MUST be session-backed. -- Tokens, cookies, or headers are transport artifacts only. -- Session validation MUST occur on every authenticated request. +- Stateful authentication modes MUST validate the authoritative session state. +- Stateless authentication modes MUST validate cryptographic token integrity and applicable token constraints. +- Client-held credentials MUST NOT independently establish authentication authority. +- Authentication validation MUST NOT bypass security checks required by the effective mode. -No authentication flow may bypass session validation. +The framework MUST NOT assume that all authentication modes require request-time session validation. --- @@ -650,15 +704,17 @@ Context integrity is mandatory for deterministic and secure request processing. --- -### 8.4 Orchestration Invariant +### 8.4 Security Coordination Invariant -All security-relevant operations MUST be orchestrated. +Security-sensitive operations MUST follow explicitly defined execution and validation boundaries. -- No security-sensitive action may execute directly against stores or domain models. -- All such actions MUST pass through an orchestrator and an authority component. -- Orchestration enforces sequencing, policy evaluation and cross-domain consistency. +- Operations requiring authorization decisions MUST use the applicable authority components. +- Complex security-sensitive workflows MUST use their designated orchestrators. +- Security validation primitives MAY operate independently when orchestration or authority evaluation is not required. +- Services, stores, and extensions MUST NOT bypass mandatory security controls. +- Cross-domain security operations MUST preserve coordination and consistency guarantees. -Bypassing orchestration or authority evaluation is explicitly forbidden. +No implementation may weaken required security controls by bypassing the designated execution path. --- @@ -676,37 +732,42 @@ Domain isolation MUST NOT be weakened by persistence or implementation convenien ### 8.6 Credential Protection Invariant -Credential material is security-critical and requires strict isolation. +Credential material is security-critical and MUST be handled through controlled security boundaries. -- Secrets MUST NOT be exposed outside the Credentials domain. -- Credentials MUST NOT be treated as identifiers or profiles. -- Credential validation MUST occur server-side. +- Credential secrets MUST NOT be persisted, logged, or exposed outside authorized credential-handling mechanisms. +- Credential validation MUST occur through trusted server-side components. +- Credentials MUST NOT be treated as user identifiers or profile data. +- Services and transport layers MAY temporarily handle credential material only as required to execute authorized authentication flows. +- Credential material MUST NOT be propagated to unrelated domains. -Credential leakage or reuse across domains is forbidden. +Credential protection MUST remain independent of the host application's persistence model. --- ### 8.7 Deterministic Evaluation Invariant -Authentication and authorization evaluation MUST be deterministic. +Authentication and authorization evaluation MUST be deterministic for equivalent inputs and equivalent authoritative security state. -- Identical inputs MUST produce identical outcomes. -- Hidden, implicit, or ambient security state is not allowed. -- Concurrent or retried requests MUST behave consistently. +- Security decisions MUST follow explicit and predictable rules. +- Hidden or uncontrolled security authority is not permitted. +- State transitions, token rotation, replay prevention, expiration, and revocation MAY legitimately change evaluation outcomes. +- Concurrent operations MUST preserve applicable security invariants. -Deterministic evaluation is required for correctness, auditability and security. +Determinism MUST NOT prevent legitimate stateful security transitions. --- ### 8.8 Revocation Invariant -Revocation is a first-class security operation. +Revocation guarantees MUST be explicit and consistent with the effective authentication mode. -- Revoked sessions or credentials MUST be rejected immediately. -- Revocation MUST be enforceable across all runtimes and authentication modes. -- Best-effort or eventual revocation is not permitted. +- Stateful authentication modes MUST enforce session revocation during subsequent authentication validation. +- Session-dependent operations MUST reject revoked sessions. +- Stateless tokens MAY remain valid until expiration unless additional revocation enforcement is provided. +- Revocation behavior MUST NOT be represented as immediate when the implementation cannot enforce immediate invalidation. +- Runtime-specific implementations MUST preserve the revocation guarantees defined for their authentication mode. -Revocation guarantees MUST NOT be weakened for performance or convenience. +Security documentation MUST accurately describe the revocation capabilities and limitations of each supported mode. --- From 7c04073a9a2c9ceb206ef9781b6be3719e3dcb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 10 Oct 2026 23:51:04 +0300 Subject: [PATCH 23/26] Update Readme & Codecov --- .github/codecov.yml | 4 ++++ README.md | 27 +++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/codecov.yml b/.github/codecov.yml index d27f31d0..1d257667 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -11,3 +11,7 @@ coverage: default: target: 80% threshold: 5% + +ignore: + - "samples/**" + - "tests/**" diff --git a/README.md b/README.md index ac155657..af4e0371 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ We keep it up-to-date with current priorities, planned features, and progress. F --- +### 💜 Join the UltimateAuth Community + +UltimateAuth is more than a framework — it's a growing open-source community. + +Join our **[Discord server](https://discord.gg/QscA86dXSR)** to connect with developers, ask questions, share ideas, discuss authentication challenges, and help shape the future of UltimateAuth. + +Whether you're using UltimateAuth, exploring the project, or interested in contributing, **you're always welcome!** + +--- + ## 🌟 Why UltimateAuth The Six-Point Principles @@ -321,12 +331,21 @@ Create accounts, simulate devices, test auth flows, and observe UltimateAuth in --- -## 🤝 Contributing +## 🤝 Community & Contributing + +UltimateAuth is built by developers, for developers — and everyone is welcome to be part of its journey. + +Whether you're reporting a bug, proposing an idea, improving documentation, or submitting code, your contribution matters. + +**You don't need to be an expert to get involved.** -UltimateAuth is a community-first framework. -We welcome proposals, discussions, architectural insights, and contributions of all sizes. +- 💜 **[Join our Discord](https://discord.gg/QscA86dXSR)** — Connect with the community and share ideas. +- 🤝 **[Contributing Guide](CONTRIBUTING.md)** — Learn how to contribute. +- 🛡️ **[Security Policy](SECURITY.md)** — Report security vulnerabilities responsibly. +- 📜 **[Code of Conduct](CODE_OF_CONDUCT.md)** — Our community values. +- 🏛️ **[Governance](GOVERNANCE.md)** — How project decisions are made. -Discussions are open — your ideas matter. +Every great open-source project starts with people willing to build something together. --- From 21ca8621a1bf4106ffacfd82594e8bb512784f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 00:44:50 +0300 Subject: [PATCH 24/26] Server Tests --- .../Orchestrator/UAuthAccessAuthority.cs | 2 +- .../Orchestrator/UAuthAccessOrchestrator.cs | 12 +- .../Core/CoreProductInfoTests.cs | 190 +++++ .../Server/AccessPipelineTests.cs | 709 ++++++++++++++++++ .../Server/CookieInfrastructureTests.cs | 649 ++++++++++++++++ .../Server/CredentialInfrastructureTests.cs | 146 ++++ .../Server/CredentialResponseWriterTests.cs | 385 ++++++++++ .../EffectiveServerOptionsProviderTests.cs | 317 +++++++- .../Server/IdentifierNormalizerTests.cs | 366 +++++++++ .../Server/ServerOptionsValidatorTests.cs | 437 ++++++++++- .../Server/UAuthServerProductInfoTests.cs | 135 ++++ .../Server/ValidateCredentialResolverTests.cs | 316 ++++++++ 12 files changed, 3653 insertions(+), 11 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/CoreProductInfoTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialInfrastructureTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialResponseWriterTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/IdentifierNormalizerTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthServerProductInfoTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateCredentialResolverTests.cs diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs index 01d91819..c8a3b6ff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs @@ -45,7 +45,7 @@ public AccessDecision Decide(AccessContext context, IEnumerable r var result = policy.Decide(context); - if (!result.IsAllowed) + if (result.IsDenied) return result; if (result.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 6fe26b2d..d96af659 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -30,12 +30,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); + if (decision.RequiresReauthentication) + throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication."); + if (!decision.IsAllowed) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); - await command.ExecuteAsync(ct); } @@ -48,12 +48,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessC var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); + if (decision.RequiresReauthentication) + throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication."); + if (!decision.IsAllowed) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); - return await command.ExecuteAsync(ct); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/CoreProductInfoTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/CoreProductInfoTests.cs new file mode 100644 index 00000000..0056fb0e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/CoreProductInfoTests.cs @@ -0,0 +1,190 @@ +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CoreProductInfoTests +{ + [Fact] + public void ProductInfo_should_have_expected_defaults() + { + var info = new UAuthProductInfo(); + + info.ProductName.Should().Be("UltimateAuth"); + info.RuntimeId.Should().NotBeNullOrWhiteSpace(); + + Guid.TryParseExact(info.RuntimeId, "N", out _) + .Should().BeTrue(); + } + + [Fact] + public void ProductInfo_should_generate_unique_runtime_ids() + { + var first = new UAuthProductInfo(); + var second = new UAuthProductInfo(); + + first.RuntimeId.Should().NotBe(second.RuntimeId); + } + + [Fact] + public void ProductInfo_should_allow_explicit_initialization() + { + var startedAt = DateTimeOffset.UtcNow; + + var info = new UAuthProductInfo + { + ProductName = "Custom UltimateAuth", + Version = "1.2.3", + InformationalVersion = "1.2.3-preview", + StartedAt = startedAt, + RuntimeId = "custom-runtime" + }; + + info.ProductName.Should().Be("Custom UltimateAuth"); + info.Version.Should().Be("1.2.3"); + info.InformationalVersion.Should().Be("1.2.3-preview"); + info.StartedAt.Should().Be(startedAt); + info.RuntimeId.Should().Be("custom-runtime"); + } + + [Fact] + public void Provider_should_return_correct_assembly_metadata() + { + var provider = CreateProvider(); + var info = provider.Get(); + + var assembly = typeof(UAuthProductInfoProvider).Assembly; + + var expectedVersion = + assembly.GetName().Version?.ToString(3) ?? "unknown"; + + var expectedInformationalVersion = + assembly.GetCustomAttribute() + ?.InformationalVersion; + + info.ProductName.Should().Be("UltimateAuth"); + info.Version.Should().Be(expectedVersion); + info.InformationalVersion.Should().Be(expectedInformationalVersion); + } + + [Fact] + public void Provider_should_capture_creation_time() + { + var before = DateTimeOffset.UtcNow; + + var provider = CreateProvider(); + + var after = DateTimeOffset.UtcNow; + var info = provider.Get(); + + info.StartedAt.Should().BeOnOrAfter(before); + info.StartedAt.Should().BeOnOrBefore(after); + } + + [Fact] + public void Provider_should_generate_valid_runtime_id() + { + var info = CreateProvider().Get(); + + info.RuntimeId.Should().NotBeNullOrWhiteSpace(); + + Guid.TryParseExact(info.RuntimeId, "N", out _) + .Should().BeTrue(); + } + + [Fact] + public void Provider_should_return_same_info_instance() + { + var provider = CreateProvider(); + + var first = provider.Get(); + var second = provider.Get(); + + first.Should().BeSameAs(second); + first.RuntimeId.Should().Be(second.RuntimeId); + first.StartedAt.Should().Be(second.StartedAt); + } + + [Fact] + public void Different_providers_should_have_different_runtime_ids() + { + var first = CreateProvider().Get(); + var second = CreateProvider().Get(); + + first.Should().NotBeSameAs(second); + first.RuntimeId.Should().NotBe(second.RuntimeId); + } + + [Fact] + public void Provider_should_not_depend_on_option_values() + { + var options = new UAuthOptions(); + + var provider = new UAuthProductInfoProvider( + Options.Create(options)); + + var info = provider.Get(); + + info.ProductName.Should().Be("UltimateAuth"); + info.Version.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Core_registration_should_register_product_info_provider_as_singleton() + { + var services = new ServiceCollection(); + + services.AddUltimateAuth(); + + var descriptors = services + .Where(d => d.ServiceType == typeof(IUAuthProductInfoProvider)) + .ToList(); + + descriptors.Should().ContainSingle(); + + descriptors[0].ImplementationType + .Should().Be(typeof(UAuthProductInfoProvider)); + + descriptors[0].Lifetime.Should().Be(ServiceLifetime.Singleton); + } + + [Fact] + public void Direct_configuration_marker_should_start_unconfigured() + { + var marker = new DirectCoreConfigurationMarker(); + + marker.IsConfigured.Should().BeFalse(); + } + + [Fact] + public void Direct_configuration_marker_should_become_configured() + { + var marker = new DirectCoreConfigurationMarker(); + + marker.MarkConfigured(); + + marker.IsConfigured.Should().BeTrue(); + } + + [Fact] + public void Direct_configuration_marker_should_remain_configured_after_multiple_calls() + { + var marker = new DirectCoreConfigurationMarker(); + + marker.MarkConfigured(); + marker.MarkConfigured(); + + marker.IsConfigured.Should().BeTrue(); + } + + private static UAuthProductInfoProvider CreateProvider() + { + return new UAuthProductInfoProvider( + Options.Create(new UAuthOptions())); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs new file mode 100644 index 00000000..dc733da2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessPipelineTests.cs @@ -0,0 +1,709 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class AccessPipelineTests +{ + [Fact] + public async Task Allowed_pipeline_should_execute_command_once() + { + var pipeline = CreatePipeline(); + var executions = 0; + + var command = new AccessCommand(_ => + { + executions++; + return Task.CompletedTask; + }); + + await pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.read"), + command); + + executions.Should().Be(1); + } + + [Fact] + public async Task Generic_pipeline_should_return_command_result() + { + var pipeline = CreatePipeline(); + var executions = 0; + + var command = new AccessCommand(_ => + { + executions++; + return Task.FromResult("expected-result"); + }); + + var result = await pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.read"), + command); + + result.Should().Be("expected-result"); + executions.Should().Be(1); + } + + [Fact] + public async Task Invariant_denial_should_stop_entire_policy_pipeline() + { + var trace = new List(); + + var invariant = Invariant(_ => + { + trace.Add("invariant"); + return AccessDecision.Deny("invariant_denied"); + }); + + var global = Policy( + _ => true, + _ => + { + trace.Add("global"); + return AccessDecision.Allow(); + }); + + var runtime = Policy( + _ => true, + _ => + { + trace.Add("runtime"); + return AccessDecision.Allow(); + }); + + var pipeline = CreatePipeline( + invariants: new[] { invariant }, + globalPolicies: new[] { global }, + runtimePolicies: new[] { runtime }); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + }); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + command); + + await act.Should() + .ThrowAsync() + .WithMessage("*invariant_denied*"); + + trace.Should().Equal("invariant"); + executed.Should().BeFalse(); + } + + [Fact] + public async Task Global_policy_denial_should_prevent_runtime_policy_and_command() + { + var trace = new List(); + + var global = Policy( + _ => true, + _ => + { + trace.Add("global"); + return AccessDecision.Deny("global_denied"); + }); + + var runtime = Policy( + _ => true, + _ => + { + trace.Add("runtime"); + return AccessDecision.Allow(); + }); + + var pipeline = CreatePipeline( + globalPolicies: new[] { global }, + runtimePolicies: new[] { runtime }); + + var executed = false; + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + })); + + await act.Should() + .ThrowAsync() + .WithMessage("*global_denied*"); + + trace.Should().Equal("global"); + executed.Should().BeFalse(); + } + + [Fact] + public async Task Non_applicable_policies_should_not_execute_decide() + { + var skipped = Policy( + _ => false, + _ => throw new Exception("Should not be evaluated")); + + var pipeline = CreatePipeline( + globalPolicies: new[] { skipped }, + runtimePolicies: new[] { skipped }); + + var executed = false; + + await pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.read"), + new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + })); + + executed.Should().BeTrue(); + + skipped.Verify( + p => p.Decide(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Global_allow_should_not_skip_runtime_denial() + { + var global = Policy( + _ => true, + _ => AccessDecision.Allow()); + + var runtime = Policy( + _ => true, + _ => AccessDecision.Deny("runtime_denied")); + + var pipeline = CreatePipeline( + globalPolicies: new[] { global }, + runtimePolicies: new[] { runtime }); + + var executed = false; + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + })); + + await act.Should() + .ThrowAsync() + .WithMessage("*runtime_denied*"); + + executed.Should().BeFalse(); + } + + [Fact] + public async Task Later_runtime_denial_should_override_earlier_allow() + { + var trace = new List(); + + var first = Policy( + _ => true, + _ => + { + trace.Add("allow"); + return AccessDecision.Allow(); + }); + + var second = Policy( + _ => true, + _ => + { + trace.Add("deny"); + return AccessDecision.Deny("permission_required"); + }); + + var third = Policy( + _ => true, + _ => + { + trace.Add("unexpected"); + return AccessDecision.Allow(); + }); + + var pipeline = CreatePipeline( + runtimePolicies: new[] { first, second, third }); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + new AccessCommand(_ => Task.CompletedTask)); + + await act.Should() + .ThrowAsync() + .WithMessage("*permission_required*"); + + trace.Should().Equal("allow", "deny"); + } + + [Fact] + public async Task Denial_without_reason_should_use_default_reason() + { + var deny = Policy( + _ => true, + _ => AccessDecision.Deny(null!)); + + var pipeline = CreatePipeline( + runtimePolicies: new[] { deny }); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + new AccessCommand(_ => Task.CompletedTask)); + + await act.Should() + .ThrowAsync() + .WithMessage("*authorization_denied*"); + } + + [Fact] + public async Task Generic_command_should_not_execute_when_denied() + { + var deny = Policy( + _ => true, + _ => AccessDecision.Deny("denied")); + + var pipeline = CreatePipeline( + runtimePolicies: new[] { deny }); + + var executions = 0; + + var command = new AccessCommand(_ => + { + executions++; + return Task.FromResult(42); + }); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.delete"), + command); + + await act.Should() + .ThrowAsync(); + + executions.Should().Be(0); + } + + [Fact] + public void Runtime_reauthentication_should_be_reported_by_authority() + { + var first = Policy( + _ => true, + _ => AccessDecision.Allow()); + + var second = Policy( + _ => true, + _ => AccessDecision.ReauthenticationRequired()); + + var authority = new UAuthAccessAuthority( + Array.Empty(), + Array.Empty()); + + var decision = authority.Decide( + TestAccessContext.WithAction("users.update"), + new[] { first.Object, second.Object }); + + decision.RequiresReauthentication.Should().BeTrue(); + decision.IsAllowed.Should().BeFalse(); + } + + [Fact] + public void Runtime_denial_should_take_priority_over_earlier_reauthentication() + { + var reauth = Policy( + _ => true, + _ => AccessDecision.ReauthenticationRequired()); + + var deny = Policy( + _ => true, + _ => AccessDecision.Deny("explicit_denial")); + + var authority = new UAuthAccessAuthority( + Array.Empty(), + Array.Empty()); + + var decision = authority.Decide( + TestAccessContext.WithAction("users.update"), + new[] { reauth.Object, deny.Object }); + + decision.IsDenied.Should().BeTrue(); + decision.DenyReason.Should().Be("explicit_denial"); + } + + [Fact] + public async Task Reauthentication_should_never_execute_command() + { + var reauth = Policy( + _ => true, + _ => AccessDecision.ReauthenticationRequired()); + + var pipeline = CreatePipeline( + runtimePolicies: new[] { reauth }); + + var executed = false; + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.update"), + new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + })); + + await act.Should() + .ThrowAsync() + .WithMessage("*Requires reauthentication*"); + + executed.Should().BeFalse(); + } + + [Fact] + public async Task Authenticated_actor_should_receive_compiled_permissions() + { + var user = UserKey.New(); + var context = TestAccessContext.ForUser( + user, + "users.read"); + + var pipeline = CreatePipeline( + permissions: new[] + { + Permission.From("users.read"), + Permission.From("roles.*") + }); + + AccessContext? captured = null; + + pipeline.PolicyProvider + .Setup(p => p.GetPolicies(It.IsAny())) + .Callback(c => captured = c) + .Returns(Array.Empty()); + + await pipeline.Orchestrator.ExecuteAsync( + context, + new AccessCommand(_ => Task.CompletedTask)); + + captured.Should().NotBeNull(); + captured.Should().NotBeSameAs(context); + + captured!.Attributes.Should() + .ContainKey(UAuthConstants.Access.Permissions); + + var compiled = captured.Attributes[ + UAuthConstants.Access.Permissions] + .Should().BeOfType() + .Subject; + + compiled.IsAllowed("users.read").Should().BeTrue(); + compiled.IsAllowed("roles.create").Should().BeTrue(); + compiled.IsAllowed("users.delete").Should().BeFalse(); + + context.Attributes.Should() + .NotContainKey(UAuthConstants.Access.Permissions); + + pipeline.PermissionStore.Verify( + s => s.GetPermissionsAsync( + context.ResourceTenant, + user, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Authority_should_receive_same_enriched_context_as_policy_provider() + { + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.read"); + + AccessContext? providerContext = null; + AccessContext? authorityContext = null; + + var runtime = Policy( + _ => true, + c => + { + authorityContext = c; + return AccessDecision.Allow(); + }); + + var pipeline = CreatePipeline( + permissions: new[] { Permission.From("users.read") }, + runtimePolicies: new[] { runtime }); + + pipeline.PolicyProvider + .Setup(p => p.GetPolicies(It.IsAny())) + .Callback(c => providerContext = c) + .Returns(new[] { runtime.Object }); + + await pipeline.Orchestrator.ExecuteAsync( + context, + new AccessCommand(_ => Task.CompletedTask)); + + providerContext.Should().NotBeNull(); + authorityContext.Should().BeSameAs(providerContext); + providerContext.Should().NotBeSameAs(context); + } + + [Fact] + public async Task Anonymous_actor_should_skip_permission_store() + { + var context = TestAccessContext.WithAction("public.read"); + var pipeline = CreatePipeline(); + + AccessContext? captured = null; + + pipeline.PolicyProvider + .Setup(p => p.GetPolicies(It.IsAny())) + .Callback(c => captured = c) + .Returns(Array.Empty()); + + await pipeline.Orchestrator.ExecuteAsync( + context, + new AccessCommand(_ => Task.CompletedTask)); + + pipeline.PermissionStore.Verify( + s => s.GetPermissionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + captured.Should().BeSameAs(context); + } + + [Fact] + public async Task Permission_store_should_receive_original_cancellation_token() + { + using var cts = new CancellationTokenSource(); + + var context = TestAccessContext.ForUser( + UserKey.New(), + "users.read"); + + var pipeline = CreatePipeline(); + + CancellationToken receivedByCommand = default; + + await pipeline.Orchestrator.ExecuteAsync( + context, + new AccessCommand(ct => + { + receivedByCommand = ct; + return Task.CompletedTask; + }), + cts.Token); + + pipeline.PermissionStore.Verify( + s => s.GetPermissionsAsync( + context.ResourceTenant, + context.ActorUserKey!.Value, + cts.Token), + Times.Once); + + receivedByCommand.Should().Be(cts.Token); + } + + [Fact] + public async Task Pre_cancelled_token_should_stop_pipeline_before_enrichment() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var pipeline = CreatePipeline(); + var executed = false; + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.ForUser( + UserKey.New(), + "users.read"), + new AccessCommand(_ => + { + executed = true; + return Task.CompletedTask; + }), + cts.Token); + + await act.Should() + .ThrowAsync(); + + executed.Should().BeFalse(); + + pipeline.PermissionStore.VerifyNoOtherCalls(); + pipeline.PolicyProvider.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Generic_command_should_receive_cancellation_token() + { + using var cts = new CancellationTokenSource(); + + var pipeline = CreatePipeline(); + CancellationToken received = default; + + var result = await pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.count"), + new AccessCommand(ct => + { + received = ct; + return Task.FromResult(42); + }), + cts.Token); + + result.Should().Be(42); + received.Should().Be(cts.Token); + } + + [Fact] + public async Task Command_exception_should_propagate() + { + var pipeline = CreatePipeline(); + + var expected = new InvalidOperationException("command_failed"); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.read"), + new AccessCommand(_ => Task.FromException(expected))); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Should().BeSameAs(expected); + } + + [Fact] + public async Task Generic_command_exception_should_propagate() + { + var pipeline = CreatePipeline(); + + var expected = new InvalidOperationException("query_failed"); + + var act = () => pipeline.Orchestrator.ExecuteAsync( + TestAccessContext.WithAction("users.read"), + new AccessCommand( + _ => Task.FromException(expected))); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Should().BeSameAs(expected); + } + + [Fact] + public void Authority_should_allow_when_no_policies_exist() + { + // Documents current default-allow behavior. + var authority = new UAuthAccessAuthority( + Array.Empty(), + Array.Empty()); + + var decision = authority.Decide( + TestAccessContext.WithAction("users.delete"), + Array.Empty()); + + decision.IsAllowed.Should().BeTrue(); + } + + [Fact] + public void Authority_should_treat_null_global_collections_as_empty() + { + var authority = new UAuthAccessAuthority( + null!, + null!); + + var decision = authority.Decide( + TestAccessContext.WithAction("users.read"), + Array.Empty()); + + decision.IsAllowed.Should().BeTrue(); + } + + private static PipelineSetup CreatePipeline( + IEnumerable>? invariants = null, + IEnumerable>? globalPolicies = null, + IEnumerable>? runtimePolicies = null, + IReadOnlyCollection? permissions = null) + { + var store = new Mock(); + var provider = new Mock(); + + store + .Setup(s => s.GetPermissionsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync( + permissions ?? Array.Empty()); + + var runtime = runtimePolicies? + .Select(p => p.Object) + .ToArray() ?? Array.Empty(); + + provider + .Setup(p => p.GetPolicies(It.IsAny())) + .Returns(runtime); + + var authority = new UAuthAccessAuthority( + invariants?.Select(i => i.Object) + ?? Array.Empty(), + globalPolicies?.Select(p => p.Object) + ?? Array.Empty()); + + var orchestrator = new UAuthAccessOrchestrator( + authority, + provider.Object, + store.Object); + + return new PipelineSetup( + orchestrator, + provider, + store); + } + + private static Mock Invariant( + Func decide) + { + var mock = new Mock(); + + mock.Setup(i => i.Decide(It.IsAny())) + .Returns((AccessContext context) => decide(context)); + + return mock; + } + + private static Mock Policy( + Func appliesTo, + Func decide) + { + var mock = new Mock(); + + mock.Setup(p => p.AppliesTo(It.IsAny())) + .Returns((AccessContext context) => appliesTo(context)); + + mock.Setup(p => p.Decide(It.IsAny())) + .Returns((AccessContext context) => decide(context)); + + return mock; + } + + private sealed record PipelineSetup( + UAuthAccessOrchestrator Orchestrator, + Mock PolicyProvider, + Mock PermissionStore); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs new file mode 100644 index 00000000..9bbd5e12 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs @@ -0,0 +1,649 @@ +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class CookieInfrastructureTests +{ + private readonly UAuthCookiePolicyBuilder _builder = new(); + private readonly UAuthCookieManager _manager = new(); + + // --------------------------------------------------------- + // COOKIE POLICY - VALIDATION + // --------------------------------------------------------- + + [Fact] + public void Build_should_throw_when_cookie_options_are_missing() + { + var context = AuthFlowTestFactory.New(); + + var response = CredentialResponseOptions.Disabled( + GrantKind.Session); + + var act = () => _builder.Build( + response, + context, + GrantKind.Session); + + act.Should() + .Throw() + .WithMessage("*Cookie options are null*"); + } + + // --------------------------------------------------------- + // COOKIE POLICY - SECURITY OPTIONS + // --------------------------------------------------------- + + [Fact] + public void Build_should_preserve_explicit_cookie_settings() + { + var cookie = CreateCookie(); + cookie.HttpOnly = true; + cookie.SecurePolicy = CookieSecurePolicy.Always; + cookie.Path = "/auth"; + cookie.Domain = ".example.com"; + cookie.SameSite = SameSiteMode.Strict; + + var options = Build( + GrantKind.Session, + cookie); + + options.HttpOnly.Should().BeTrue(); + options.Secure.Should().BeTrue(); + options.Path.Should().Be("/auth"); + options.Domain.Should().Be(".example.com"); + options.SameSite.Should().Be(SameSiteMode.Strict); + } + + [Fact] + public void Build_should_support_non_http_only_cookie_configuration() + { + var cookie = CreateCookie(); + cookie.HttpOnly = false; + + var options = Build( + GrantKind.Session, + cookie); + + options.HttpOnly.Should().BeFalse(); + } + + [Fact] + public void Build_should_disable_secure_when_policy_is_not_always() + { + var cookie = CreateCookie(); + cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + + var options = Build( + GrantKind.Session, + cookie); + + options.Secure.Should().BeFalse(); + } + + [Fact] + public void Build_should_preserve_host_only_cookie_configuration() + { + var cookie = CreateCookie(); + cookie.Domain = null; + cookie.Path = "/"; + + var options = Build( + GrantKind.Session, + cookie); + + options.Domain.Should().BeNull(); + options.Path.Should().Be("/"); + } + + // --------------------------------------------------------- + // COOKIE POLICY - SAME SITE + // --------------------------------------------------------- + + [Theory] + [InlineData( + UAuthHubDeploymentMode.Embedded, + SameSiteMode.Strict)] + [InlineData( + UAuthHubDeploymentMode.Integrated, + SameSiteMode.Lax)] + [InlineData( + UAuthHubDeploymentMode.External, + SameSiteMode.None)] + [InlineData( + (UAuthHubDeploymentMode)999, + SameSiteMode.Lax)] + public void Build_should_resolve_same_site_from_deployment_mode( + UAuthHubDeploymentMode deploymentMode, + SameSiteMode expected) + { + var cookie = CreateCookie(); + cookie.SameSite = null; + + var context = AuthFlowTestFactory.New(); + context.OriginalOptions.HubDeploymentMode = deploymentMode; + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.SameSite.Should().Be(expected); + } + + [Theory] + [InlineData(SameSiteMode.Strict)] + [InlineData(SameSiteMode.Lax)] + [InlineData(SameSiteMode.None)] + public void Explicit_same_site_should_override_deployment_mode( + SameSiteMode explicitMode) + { + var cookie = CreateCookie(); + cookie.SameSite = explicitMode; + + var context = AuthFlowTestFactory.New(); + context.OriginalOptions.HubDeploymentMode = + UAuthHubDeploymentMode.External; + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.SameSite.Should().Be(explicitMode); + } + + // --------------------------------------------------------- + // COOKIE POLICY - LIFETIME PRECEDENCE + // --------------------------------------------------------- + + [Fact] + public void Explicit_max_age_should_override_all_other_lifetimes() + { + var cookie = CreateCookie(); + cookie.MaxAge = TimeSpan.FromMinutes(30); + cookie.Lifetime.AbsoluteLifetimeOverride = + TimeSpan.FromHours(2); + cookie.Lifetime.IdleBuffer = + TimeSpan.FromMinutes(5); + + var context = AuthFlowTestFactory.New(); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromHours(4); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(35)); + } + + [Fact] + public void Absolute_lifetime_override_should_take_precedence_over_grant_lifetime() + { + var cookie = CreateCookie(); + cookie.MaxAge = null; + cookie.Lifetime.AbsoluteLifetimeOverride = + TimeSpan.FromMinutes(45); + cookie.Lifetime.IdleBuffer = + TimeSpan.FromMinutes(5); + + var context = AuthFlowTestFactory.New(); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromHours(4); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + // Current implementation adds IdleBuffer even + // when AbsoluteLifetimeOverride is specified. + options.MaxAge.Should().Be(TimeSpan.FromMinutes(50)); + } + + [Fact] + public void Null_idle_buffer_should_be_treated_as_zero() + { + var cookie = CreateCookie(); + cookie.MaxAge = TimeSpan.FromMinutes(30); + cookie.Lifetime.IdleBuffer = null; + + var options = Build( + GrantKind.Session, + cookie); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(30)); + } + + [Fact] + public void Zero_idle_buffer_should_not_extend_lifetime() + { + var cookie = CreateCookie(); + cookie.MaxAge = TimeSpan.FromMinutes(30); + cookie.Lifetime.IdleBuffer = TimeSpan.Zero; + + var options = Build( + GrantKind.Session, + cookie); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(30)); + } + + // --------------------------------------------------------- + // COOKIE POLICY - SESSION LIFETIME + // --------------------------------------------------------- + + [Fact] + public void Pure_opaque_session_should_use_idle_timeout() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New( + mode: UAuthMode.PureOpaque); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromMinutes(20); + + context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(25)); + } + + [Fact] + public void Hybrid_session_should_use_refresh_lifetime_when_longer() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New( + mode: UAuthMode.Hybrid); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromMinutes(20); + + context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.MaxAge.Should().Be( + TimeSpan.FromDays(7) + TimeSpan.FromMinutes(5)); + } + + [Fact] + public void Hybrid_session_should_use_idle_timeout_when_longer() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New( + mode: UAuthMode.Hybrid); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromHours(12); + + context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + TimeSpan.FromHours(2); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.MaxAge.Should().Be( + TimeSpan.FromHours(12) + TimeSpan.FromMinutes(5)); + } + + [Fact] + public void Non_hybrid_session_should_use_idle_timeout() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New( + mode: UAuthMode.PureJwt); + + context.EffectiveOptions.Options.Session.IdleTimeout = + TimeSpan.FromMinutes(15); + + context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(1); + + var options = _builder.Build( + CreateResponse(GrantKind.Session, cookie), + context, + GrantKind.Session); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(20)); + } + + // --------------------------------------------------------- + // COOKIE POLICY - TOKEN LIFETIMES + // --------------------------------------------------------- + + [Fact] + public void Access_token_cookie_should_use_access_token_lifetime() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New(); + + context.EffectiveOptions.Options.Token.AccessTokenLifetime = + TimeSpan.FromMinutes(15); + + var options = _builder.Build( + CreateResponse(GrantKind.AccessToken, cookie), + context, + GrantKind.AccessToken); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(20)); + } + + [Fact] + public void Refresh_token_cookie_should_use_refresh_token_lifetime() + { + var cookie = CreateCookie(); + + var context = AuthFlowTestFactory.New(); + + context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(14); + + var options = _builder.Build( + CreateResponse(GrantKind.RefreshToken, cookie), + context, + GrantKind.RefreshToken); + + options.MaxAge.Should().Be( + TimeSpan.FromDays(14) + TimeSpan.FromMinutes(5)); + } + + [Fact] + public void Unknown_grant_kind_without_override_should_not_set_max_age() + { + var cookie = CreateCookie(); + cookie.MaxAge = null; + cookie.Lifetime.AbsoluteLifetimeOverride = null; + + var options = Build( + (GrantKind)999, + cookie); + + options.MaxAge.Should().BeNull(); + } + + [Fact] + public void Unknown_grant_kind_should_still_support_explicit_max_age() + { + var cookie = CreateCookie(); + cookie.MaxAge = TimeSpan.FromMinutes(10); + + var options = Build( + (GrantKind)999, + cookie); + + options.MaxAge.Should().Be(TimeSpan.FromMinutes(15)); + } + + // --------------------------------------------------------- + // COOKIE MANAGER - WRITE + // --------------------------------------------------------- + + [Fact] + public void Write_should_append_cookie_to_http_response() + { + var context = new DefaultHttpContext(); + + var options = new CookieOptions + { + HttpOnly = true, + Secure = true, + Path = "/", + SameSite = SameSiteMode.Strict, + MaxAge = TimeSpan.FromMinutes(30) + }; + + _manager.Write( + context, + "uauth-session", + "session-123", + options); + + var header = context.Response.Headers.SetCookie.ToString(); + + header.Should().Contain("uauth-session=session-123"); + header.Should().Contain("path=/"); + header.Should().Contain("secure"); + header.Should().Contain("httponly"); + header.Should().Contain("samesite=strict"); + header.Should().Contain("max-age=1800"); + } + + [Fact] + public void Write_should_preserve_custom_cookie_path_and_domain() + { + var context = new DefaultHttpContext(); + + var options = new CookieOptions + { + Path = "/auth", + Domain = ".example.com", + HttpOnly = true, + Secure = true + }; + + _manager.Write( + context, + "uauth-refresh", + "refresh-123", + options); + + var header = context.Response.Headers.SetCookie + .ToString() + .ToLowerInvariant(); + + header.Should().Contain("uauth-refresh=refresh-123"); + header.Should().Contain("path=/auth"); + header.Should().Contain("domain=.example.com"); + } + + [Fact] + public void Write_should_append_multiple_cookies_without_overwriting() + { + var context = new DefaultHttpContext(); + + _manager.Write( + context, + "uauth-session", + "session-123", + new CookieOptions()); + + _manager.Write( + context, + "uauth-refresh", + "refresh-456", + new CookieOptions()); + + var headers = context.Response.Headers.SetCookie; + + headers.Count.Should().Be(2); + + headers[0].Should().Contain("uauth-session=session-123"); + headers[1].Should().Contain("uauth-refresh=refresh-456"); + } + + // --------------------------------------------------------- + // COOKIE MANAGER - READ + // --------------------------------------------------------- + + [Fact] + public void TryRead_should_return_cookie_value_when_present() + { + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth-session=session-123; other=value"; + + var found = _manager.TryRead( + context, + "uauth-session", + out var value); + + found.Should().BeTrue(); + value.Should().Be("session-123"); + } + + [Fact] + public void TryRead_should_return_false_when_cookie_is_missing() + { + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "other=value"; + + var found = _manager.TryRead( + context, + "uauth-session", + out _); + + found.Should().BeFalse(); + } + + [Fact] + public void TryRead_should_not_confuse_similar_cookie_names() + { + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth-session-extra=wrong; uauth-session=correct"; + + var found = _manager.TryRead( + context, + "uauth-session", + out var value); + + found.Should().BeTrue(); + value.Should().Be("correct"); + } + + [Fact] + public void TryRead_should_decode_cookie_value() + { + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth-session=hello%20world"; + + var found = _manager.TryRead( + context, + "uauth-session", + out var value); + + found.Should().BeTrue(); + value.Should().Be("hello world"); + } + + // --------------------------------------------------------- + // COOKIE MANAGER - DELETE + // --------------------------------------------------------- + + [Fact] + public void Delete_should_append_expired_cookie_to_response() + { + var context = new DefaultHttpContext(); + + _manager.Delete( + context, + "uauth-session"); + + var header = context.Response.Headers.SetCookie + .ToString() + .ToLowerInvariant(); + + header.Should().Contain("uauth-session="); + header.Should().Contain("expires="); + } + + [Fact] + public void Delete_should_not_modify_unrelated_request_cookies() + { + var context = new DefaultHttpContext(); + + context.Request.Headers.Cookie = + "uauth-session=session-123; other=value"; + + _manager.Delete( + context, + "uauth-session"); + + // Deletion instructs the browser to remove the cookie. + // It does not mutate the current HTTP request. + _manager.TryRead( + context, + "other", + out var other).Should().BeTrue(); + + other.Should().Be("value"); + } + + // --------------------------------------------------------- + // HELPERS + // --------------------------------------------------------- + + private CookieOptions Build( + GrantKind kind, + UAuthCookieOptions cookie, + UAuthMode mode = UAuthMode.PureOpaque) + { + var context = AuthFlowTestFactory.New(mode: mode); + + return _builder.Build( + CreateResponse(kind, cookie), + context, + kind); + } + + private static UAuthCookieOptions CreateCookie() + { + return new UAuthCookieOptions + { + Name = "uauth-test", + HttpOnly = true, + SecurePolicy = CookieSecurePolicy.Always, + Path = "/", + SameSite = null, + MaxAge = null, + Lifetime = new UAuthCookieLifetimeOptions + { + IdleBuffer = TimeSpan.FromMinutes(5) + } + }; + } + + private static CredentialResponseOptions CreateResponse( + GrantKind kind, + UAuthCookieOptions cookie) + { + return new CredentialResponseOptions + { + Kind = kind, + Mode = TokenResponseMode.Cookie + }.WithCookie(cookie); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialInfrastructureTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialInfrastructureTests.cs new file mode 100644 index 00000000..72d0d4fa --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialInfrastructureTests.cs @@ -0,0 +1,146 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CredentialInfrastructureTests +{ + [Theory] + [InlineData("/", false)] + [InlineData("/login", false)] + [InlineData("/api", true)] + [InlineData("/api/users", true)] + [InlineData("/apiv2", false)] + public void Primary_resolver_should_classify_request_path( + string path, + bool isApi) + { + var resolver = CreatePrimaryResolver(); + var context = new DefaultHttpContext(); + + context.Request.Path = path; + + var result = resolver.Resolve(context); + + result.Should().Be( + isApi + ? PrimaryTokenKind.AccessToken + : PrimaryTokenKind.Session); + } + + [Fact] + public void Authorization_header_should_select_api_credential() + { + var resolver = CreatePrimaryResolver(); + var context = new DefaultHttpContext(); + + context.Request.Path = "/dashboard"; + context.Request.Headers.Authorization = "Bearer token"; + + resolver.Resolve(context) + .Should().Be(PrimaryTokenKind.AccessToken); + } + + [Fact] + public void Primary_resolver_should_use_configured_credential_kinds() + { + var options = new UAuthServerOptions(); + + options.PrimaryCredential.Ui = PrimaryTokenKind.AccessToken; + options.PrimaryCredential.Api = PrimaryTokenKind.Session; + + var resolver = new PrimaryCredentialResolver( + Options.Create(options)); + + var ui = new DefaultHttpContext(); + ui.Request.Path = "/dashboard"; + + var api = new DefaultHttpContext(); + api.Request.Path = "/api/users"; + + resolver.Resolve(ui).Should().Be(PrimaryTokenKind.AccessToken); + resolver.Resolve(api).Should().Be(PrimaryTokenKind.Session); + } + + [Theory] + [InlineData("token", "Bearer token")] + [InlineData("abc123", "Bearer abc123")] + public void Header_builder_should_format_bearer_tokens( + string raw, + string expected) + { + var builder = new UAuthHeaderPolicyBuilder(); + + var options = new CredentialResponseOptions + { + HeaderFormat = HeaderTokenFormat.Bearer + }; + + builder.BuildHeaderValue(raw, options, null!) + .Should().Be(expected); + } + + [Fact] + public void Header_builder_should_preserve_raw_tokens() + { + var builder = new UAuthHeaderPolicyBuilder(); + + var options = new CredentialResponseOptions + { + HeaderFormat = HeaderTokenFormat.Raw + }; + + builder.BuildHeaderValue("raw-token", options, null!) + .Should().Be("raw-token"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Header_builder_should_reject_empty_values(string? value) + { + var builder = new UAuthHeaderPolicyBuilder(); + + var options = new CredentialResponseOptions + { + HeaderFormat = HeaderTokenFormat.Bearer + }; + + Action act = () => + builder.BuildHeaderValue(value!, options, null!); + + act.Should() + .Throw() + .WithParameterName("rawValue"); + } + + [Fact] + public void Header_builder_should_reject_unsupported_format() + { + var builder = new UAuthHeaderPolicyBuilder(); + + var options = new CredentialResponseOptions + { + HeaderFormat = (HeaderTokenFormat)999 + }; + + Action act = () => + builder.BuildHeaderValue("token", options, null!); + + act.Should() + .Throw() + .WithMessage("*Unsupported header token format*"); + } + + private static PrimaryCredentialResolver CreatePrimaryResolver() + { + return new PrimaryCredentialResolver( + Options.Create(new UAuthServerOptions())); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialResponseWriterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialResponseWriterTests.cs new file mode 100644 index 00000000..dbc6b7dc --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CredentialResponseWriterTests.cs @@ -0,0 +1,385 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CredentialResponseWriterTests +{ + [Theory] + [InlineData(GrantKind.Session)] + [InlineData(GrantKind.AccessToken)] + [InlineData(GrantKind.RefreshToken)] + public void None_delivery_should_not_write_credentials(GrantKind kind) + { + var setup = CreateWriter(CreateResponse()); + var context = TestHttpContext.Create(); + + setup.Writer.WriteInternal(context, kind, "secret-token"); + + context.Response.Headers.Should().BeEmpty(); + setup.CookieManager.WriteCount.Should().Be(0); + setup.CookiePolicy.BuildCount.Should().Be(0); + } + + [Theory] + [InlineData(GrantKind.Session)] + [InlineData(GrantKind.AccessToken)] + [InlineData(GrantKind.RefreshToken)] + public void Header_delivery_should_write_correct_grant(GrantKind kind) + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + HeaderFormat = HeaderTokenFormat.Bearer + }; + + var setup = CreateWriter(CreateResponse(kind, delivery)); + var context = TestHttpContext.Create(); + + setup.Writer.WriteInternal(context, kind, "secret-token"); + + context.Response.Headers.Authorization.ToString() + .Should().Be("Bearer secret-token"); + + setup.CookieManager.WriteCount.Should().Be(0); + } + + [Fact] + public void Raw_header_delivery_should_not_add_bearer_prefix() + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + HeaderFormat = HeaderTokenFormat.Raw + }; + + var setup = CreateWriter( + CreateResponse(GrantKind.AccessToken, delivery)); + + var context = TestHttpContext.Create(); + + setup.Writer.WriteInternal( + context, + GrantKind.AccessToken, + "raw-token"); + + context.Response.Headers.Authorization.ToString() + .Should().Be("raw-token"); + } + + [Fact] + public void Header_delivery_should_support_custom_header_name() + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + Name = "X-UAuth-Token", + HeaderFormat = HeaderTokenFormat.Raw + }; + + var setup = CreateWriter( + CreateResponse(GrantKind.AccessToken, delivery)); + + var context = TestHttpContext.Create(); + + setup.Writer.WriteInternal( + context, + GrantKind.AccessToken, + "custom-token"); + + context.Response.Headers["X-UAuth-Token"].ToString() + .Should().Be("custom-token"); + + context.Response.Headers.ContainsKey("Authorization") + .Should().BeFalse(); + } + + [Fact] + public void Header_delivery_should_replace_existing_header() + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + HeaderFormat = HeaderTokenFormat.Bearer + }; + + var setup = CreateWriter( + CreateResponse(GrantKind.AccessToken, delivery)); + + var context = TestHttpContext.Create(); + context.Response.Headers.Authorization = "Bearer old-token"; + + setup.Writer.WriteInternal( + context, + GrantKind.AccessToken, + "new-token"); + + context.Response.Headers.Authorization.ToString() + .Should().Be("Bearer new-token"); + } + + [Theory] + [InlineData(GrantKind.Session)] + [InlineData(GrantKind.AccessToken)] + [InlineData(GrantKind.RefreshToken)] + public void Cookie_delivery_should_write_correct_credential( + GrantKind kind) + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie + }.WithCookie(new UAuthCookieOptions + { + Name = "uauth-credential" + }); + + var setup = CreateWriter(CreateResponse(kind, delivery)); + var context = TestHttpContext.Create(); + + setup.Writer.WriteInternal(context, kind, "cookie-token"); + + setup.CookieManager.WriteCount.Should().Be(1); + setup.CookieManager.LastName.Should().Be("uauth-credential"); + setup.CookieManager.LastValue.Should().Be("cookie-token"); + setup.CookieManager.LastContext.Should().BeSameAs(context); + + setup.CookiePolicy.BuildCount.Should().Be(1); + setup.CookiePolicy.LastKind.Should().Be(kind); + setup.CookiePolicy.LastDelivery.Should().BeSameAs(delivery); + setup.CookiePolicy.LastAuth.Should().BeSameAs(setup.Auth); + + setup.CookieManager.LastOptions + .Should().BeSameAs(setup.CookiePolicy.CookieOptions); + } + + [Theory] + [InlineData(GrantKind.Session)] + [InlineData(GrantKind.AccessToken)] + [InlineData(GrantKind.RefreshToken)] + public void Cookie_delivery_without_options_should_throw( + GrantKind kind) + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie + }; + + var setup = CreateWriter(CreateResponse(kind, delivery)); + var context = TestHttpContext.Create(); + + Action act = () => + setup.Writer.WriteInternal(context, kind, "token"); + + act.Should() + .Throw() + .WithMessage("*Cookie options missing*"); + + setup.CookieManager.WriteCount.Should().Be(0); + setup.CookiePolicy.BuildCount.Should().Be(0); + } + + [Theory] + [InlineData(GrantKind.Session)] + [InlineData(GrantKind.AccessToken)] + [InlineData(GrantKind.RefreshToken)] + public void Body_delivery_should_throw_not_supported( + GrantKind kind) + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Body + }; + + var setup = CreateWriter(CreateResponse(kind, delivery)); + var context = TestHttpContext.Create(); + + Action act = () => + setup.Writer.WriteInternal(context, kind, "token"); + + act.Should() + .Throw() + .WithMessage("*Body token delivery is not supported*"); + } + + [Fact] + public void Unsupported_grant_kind_should_throw() + { + var setup = CreateWriter(CreateResponse()); + var context = TestHttpContext.Create(); + + Action act = () => + setup.Writer.WriteInternal( + context, + (GrantKind)999, + "token"); + + act.Should() + .Throw() + .WithParameterName("kind"); + } + + [Fact] + public void Header_policy_failure_should_propagate() + { + var delivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + HeaderFormat = (HeaderTokenFormat)999 + }; + + var setup = CreateWriter( + CreateResponse(GrantKind.AccessToken, delivery)); + + var context = TestHttpContext.Create(); + + Action act = () => + setup.Writer.WriteInternal( + context, + GrantKind.AccessToken, + "token"); + + act.Should() + .Throw() + .WithMessage("*Unsupported header token format*"); + + context.Response.Headers.ContainsKey("Authorization") + .Should().BeFalse(); + } + + private static WriterSetup CreateWriter(EffectiveAuthResponse response) + { + var auth = AuthFlowTestFactory.New(response: response); + var accessor = new FakeAuthFlowContextAccessor(auth); + var cookieManager = new FakeCookieManager(); + var cookiePolicy = new FakeCookiePolicyBuilder(); + + var writer = new CredentialResponseWriter( + accessor, + cookieManager, + cookiePolicy, + new UAuthHeaderPolicyBuilder()); + + return new WriterSetup( + writer, + auth, + cookieManager, + cookiePolicy); + } + + private static EffectiveAuthResponse CreateResponse( + GrantKind? kind = null, + CredentialResponseOptions? delivery = null) + { + var session = CredentialResponseOptions.Disabled(GrantKind.Session); + var access = CredentialResponseOptions.Disabled(GrantKind.AccessToken); + var refresh = CredentialResponseOptions.Disabled(GrantKind.RefreshToken); + + switch (kind) + { + case GrantKind.Session: + session = delivery!; + break; + + case GrantKind.AccessToken: + access = delivery!; + break; + + case GrantKind.RefreshToken: + refresh = delivery!; + break; + } + + return new EffectiveAuthResponse( + session, + access, + refresh, + EffectiveRedirectResponse.Disabled); + } + + private sealed record WriterSetup( + CredentialResponseWriter Writer, + AuthFlowContext Auth, + FakeCookieManager CookieManager, + FakeCookiePolicyBuilder CookiePolicy); + + private sealed class FakeAuthFlowContextAccessor + : IAuthFlowContextAccessor + { + public FakeAuthFlowContextAccessor(AuthFlowContext current) + { + Current = current; + } + + public AuthFlowContext Current { get; } + } + + private sealed class FakeCookieManager : IUAuthCookieManager + { + public int WriteCount { get; private set; } + public HttpContext? LastContext { get; private set; } + public string? LastName { get; private set; } + public string? LastValue { get; private set; } + public CookieOptions? LastOptions { get; private set; } + + public void Write( + HttpContext context, + string name, + string value, + CookieOptions options) + { + WriteCount++; + LastContext = context; + LastName = name; + LastValue = value; + LastOptions = options; + } + + public bool TryRead( + HttpContext context, + string name, + out string value) + { + value = string.Empty; + return false; + } + + public void Delete(HttpContext context, string name) + { + } + } + + private sealed class FakeCookiePolicyBuilder + : IUAuthCookiePolicyBuilder + { + public int BuildCount { get; private set; } + public GrantKind? LastKind { get; private set; } + public CredentialResponseOptions? LastDelivery { get; private set; } + public AuthFlowContext? LastAuth { get; private set; } + + public CookieOptions CookieOptions { get; } = new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict + }; + + public CookieOptions Build( + CredentialResponseOptions response, + AuthFlowContext context, + GrantKind kind) + { + BuildCount++; + LastDelivery = response; + LastAuth = context; + LastKind = kind; + + return CookieOptions; + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs index f2d98b20..a386347f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -1,15 +1,13 @@ using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -85,6 +83,319 @@ public void Each_Call_Returns_New_EffectiveOptions_Instance() Assert.NotSame(first.Options, second.Options); } + + [Theory] + [InlineData(UAuthMode.PureOpaque)] + [InlineData(UAuthMode.Hybrid)] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public void Effective_options_should_apply_defaults_for_each_auth_mode( + UAuthMode mode) + { + var original = new UAuthServerOptions(); + var provider = CreateProviderForMode(original, mode); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Mode.Should().Be(mode); + effective.Options.Should().NotBeSameAs(original); + } + + [Fact] + public void SemiHybrid_should_apply_expected_session_defaults() + { + var original = new UAuthServerOptions + { + Session = + { + SlidingExpiration = true, + TouchInterval = TimeSpan.FromMinutes(5) + } + }; + + var provider = CreateProviderForMode(original, UAuthMode.SemiHybrid); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Session.SlidingExpiration.Should().BeFalse(); + effective.Options.Session.TouchInterval.Should().BeNull(); + + // The original configuration must remain unchanged. + original.Session.SlidingExpiration.Should().BeTrue(); + original.Session.TouchInterval.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void SemiHybrid_should_apply_expected_token_defaults() + { + var original = new UAuthServerOptions + { + Token = + { + IssueJwt = false, + IssueOpaque = false, + AddJwtIdClaim = false, + AccessTokenLifetime = TimeSpan.FromHours(1), + RefreshTokenLifetime = TimeSpan.FromHours(2) + } + }; + + var provider = CreateProviderForMode(original, UAuthMode.SemiHybrid); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + var token = effective.Options.Token; + + token.IssueJwt.Should().BeTrue(); + token.IssueOpaque.Should().BeTrue(); + token.AddJwtIdClaim.Should().BeTrue(); + token.AccessTokenLifetime.Should().Be(TimeSpan.FromMinutes(10)); + token.RefreshTokenLifetime.Should().Be(TimeSpan.FromDays(7)); + } + + [Fact] + public void SemiHybrid_should_apply_expected_cookie_defaults() + { + var provider = CreateProviderForMode( + new UAuthServerOptions(), + UAuthMode.SemiHybrid); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Cookie.AccessToken.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + + effective.Options.Cookie.RefreshToken.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void PureOpaque_should_initialize_missing_optional_session_defaults() + { + var original = new UAuthServerOptions + { + Session = + { + IdleTimeout = null, + TouchInterval = null + } + }; + + var provider = CreateProviderForMode(original, UAuthMode.PureOpaque); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Session.IdleTimeout.Should().Be(TimeSpan.FromDays(7)); + effective.Options.Session.TouchInterval.Should().Be(TimeSpan.FromDays(1)); + effective.Options.Session.MaxLifetime.Should().BeNull(); + effective.Options.Session.DeviceMismatchBehavior + .Should().Be(DeviceMismatchBehavior.Allow); + + effective.Options.Token.IssueJwt.Should().BeFalse(); + effective.Options.Token.IssueOpaque.Should().BeFalse(); + effective.Options.Token.IssueRefresh.Should().BeFalse(); + + effective.Options.Cookie.Session.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromDays(2)); + + effective.Options.AuthResponse.RefreshTokenDelivery.Mode + .Should().Be(TokenResponseMode.None); + + effective.Options.AuthResponse.RefreshTokenDelivery.TokenFormat + .Should().Be(TokenFormat.Opaque); + } + + [Fact] + public void PureOpaque_should_preserve_explicit_optional_session_values() + { + var original = new UAuthServerOptions + { + Session = + { + IdleTimeout = TimeSpan.FromHours(4), + TouchInterval = TimeSpan.FromMinutes(15), + MaxLifetime = TimeSpan.FromDays(30) + } + }; + + var provider = CreateProviderForMode(original, UAuthMode.PureOpaque); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Session.IdleTimeout.Should().Be(TimeSpan.FromHours(4)); + effective.Options.Session.TouchInterval.Should().Be(TimeSpan.FromMinutes(15)); + effective.Options.Session.MaxLifetime.Should().Be(TimeSpan.FromDays(30)); + } + + [Fact] + public void Hybrid_should_apply_expected_defaults() + { + var provider = CreateProviderForMode( + new UAuthServerOptions(), + UAuthMode.Hybrid); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + var options = effective.Options; + + options.Session.SlidingExpiration.Should().BeTrue(); + options.Session.TouchInterval.Should().BeNull(); + + options.Token.IssueJwt.Should().BeTrue(); + options.Token.IssueOpaque.Should().BeTrue(); + options.Token.AccessTokenLifetime.Should().Be(TimeSpan.FromMinutes(10)); + options.Token.RefreshTokenLifetime.Should().Be(TimeSpan.FromDays(7)); + + options.Cookie.Session.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + + options.Cookie.RefreshToken.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + + options.AuthResponse.RefreshTokenDelivery.Mode + .Should().Be(TokenResponseMode.Cookie); + + options.AuthResponse.RefreshTokenDelivery.TokenFormat + .Should().Be(TokenFormat.Opaque); + } + + [Fact] + public void PureJwt_should_apply_stateless_defaults() + { + var original = new UAuthServerOptions + { + Session = + { + SlidingExpiration = true, + IdleTimeout = TimeSpan.FromDays(7), + MaxLifetime = TimeSpan.FromDays(30), + TouchInterval = TimeSpan.FromMinutes(5) + } + }; + + var provider = CreateProviderForMode(original, UAuthMode.PureJwt); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.Api); + + var options = effective.Options; + + options.Session.SlidingExpiration.Should().BeFalse(); + options.Session.IdleTimeout.Should().BeNull(); + options.Session.MaxLifetime.Should().BeNull(); + options.Session.TouchInterval.Should().BeNull(); + + options.Token.IssueJwt.Should().BeTrue(); + options.Token.IssueOpaque.Should().BeFalse(); + options.Token.AddJwtIdClaim.Should().BeTrue(); + options.Token.AccessTokenLifetime.Should().Be(TimeSpan.FromMinutes(10)); + options.Token.RefreshTokenLifetime.Should().Be(TimeSpan.FromDays(7)); + + options.Cookie.AccessToken.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + + options.Cookie.RefreshToken.Lifetime.IdleBuffer + .Should().Be(TimeSpan.FromMinutes(5)); + + // No mutation of original options. + original.Session.MaxLifetime.Should().Be(TimeSpan.FromDays(30)); + } + + [Fact] + public void Unsupported_auth_mode_should_throw() + { + var provider = CreateProviderForMode( + new UAuthServerOptions(), + (UAuthMode)999); + + Action act = () => provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.Api); + + act.Should() + .Throw() + .WithMessage("*Unsupported UAuthMode*"); + } + + [Fact] + public void SemiHybrid_mode_configuration_should_override_defaults() + { + var original = new UAuthServerOptions(); + + original.ConfigureMode(UAuthMode.SemiHybrid, options => + { + options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(3); + options.Session.SlidingExpiration = true; + }); + + var provider = CreateProviderForMode(original, UAuthMode.SemiHybrid); + + var effective = provider.GetEffective( + TenantKey.Single, + AuthFlowType.Login, + UAuthClientProfile.BlazorServer); + + effective.Options.Token.AccessTokenLifetime + .Should().Be(TimeSpan.FromMinutes(3)); + + effective.Options.Session.SlidingExpiration.Should().BeTrue(); + + // The base configuration is not mutated. + original.Token.AccessTokenLifetime.Should().Be(TimeSpan.FromMinutes(10)); + original.Session.SlidingExpiration.Should().BeTrue(); + } + + private static EffectiveServerOptionsProvider CreateProviderForMode( + UAuthServerOptions options, + UAuthMode mode) + { + return new EffectiveServerOptionsProvider( + Microsoft.Extensions.Options.Options.Create(options), + new FixedAuthModeResolver(mode)); + } + + private sealed class FixedAuthModeResolver : IEffectiveAuthModeResolver + { + private readonly UAuthMode _mode; + + public FixedAuthModeResolver(UAuthMode mode) + { + _mode = mode; + } + + public UAuthMode Resolve( + UAuthClientProfile clientProfile, + AuthFlowType flowType) + { + return _mode; + } + } + + // TODO: Discuss and enable //[Fact] //public void FlowType_Is_Passed_To_ModeResolver() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/IdentifierNormalizerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/IdentifierNormalizerTests.cs new file mode 100644 index 00000000..f3577924 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/IdentifierNormalizerTests.cs @@ -0,0 +1,366 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class IdentifierNormalizerTests +{ + [Theory] + [InlineData(UserIdentifierType.Email)] + [InlineData(UserIdentifierType.Phone)] + [InlineData(UserIdentifierType.Username)] + [InlineData((UserIdentifierType)999)] + public void Empty_identifier_should_be_rejected(UserIdentifierType type) + { + var normalizer = CreateNormalizer(); + + foreach (var input in new[] { "", " ", "\t\r\n" }) + { + var result = normalizer.Normalize(type, input); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("identifier_empty"); + result.Normalized.Should().BeEmpty(); + result.Raw.Should().Be(input); + } + } + + [Fact] + public void Null_identifier_should_be_rejected() + { + var normalizer = CreateNormalizer(); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + null!); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("identifier_empty"); + result.Normalized.Should().BeEmpty(); + } + + [Fact] + public void Basic_normalization_should_trim_and_apply_nfkc() + { + var normalizer = CreateNormalizer(); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + " Ali123 "); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("ali123"); + result.Raw.Should().Be(" Ali123 "); + } + + [Fact] + public void Basic_normalization_should_remove_control_and_zero_width_characters() + { + var normalizer = CreateNormalizer(); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + "ab\u200Bc\u200Cd\u200De\uFEFFf\u0001"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("abcdef"); + } + + [Theory] + [InlineData("ab", "username_invalid_length")] + [InlineData("a", "username_invalid_length")] + public void Username_should_reject_short_values( + string input, + string expectedError) + { + var normalizer = CreateNormalizer(); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + input); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be(expectedError); + } + + [Fact] + public void Username_should_accept_minimum_length() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Username, + "abc"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("abc"); + } + + [Fact] + public void Username_should_accept_maximum_length() + { + var input = new string('a', 256); + + var result = CreateNormalizer().Normalize( + UserIdentifierType.Username, + input); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().HaveLength(256); + } + + [Fact] + public void Username_should_reject_more_than_maximum_length() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Username, + new string('a', 257)); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("username_invalid_length"); + } + + [Theory] + [InlineData(CaseHandling.ToLower, "mixeduser")] + [InlineData(CaseHandling.ToUpper, "MIXEDUSER")] + public void Username_should_apply_case_policy( + CaseHandling policy, + string expected) + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.UsernameCase = policy; + }); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + "MixedUser"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be(expected); + } + + [Fact] + public void Username_should_preserve_case_when_policy_is_not_lower_or_upper() + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.UsernameCase = (CaseHandling)999; + }); + + var result = normalizer.Normalize( + UserIdentifierType.Username, + "MixedUser"); + + result.Normalized.Should().Be("MixedUser"); + } + + [Theory] + [InlineData("plainaddress")] + [InlineData("@example.com")] + [InlineData("a@@example.com")] + [InlineData("a@b@example.com")] + public void Email_should_reject_invalid_at_sign_structure(string input) + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Email, + input); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("email_invalid_format"); + } + + [Theory] + [InlineData("user@")] + [InlineData("user@example")] + [InlineData("user@ ")] + public void Email_should_reject_missing_or_invalid_domain(string input) + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Email, + input); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("email_invalid_domain"); + } + + [Fact] + public void Email_should_convert_internationalized_domain_to_ascii() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Email, + "User@bücher.de"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("user@xn--bcher-kva.de"); + } + + [Fact] + public void Email_should_reject_domain_when_idn_conversion_fails() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Email, + "user@bad..example"); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("email_invalid_domain"); + } + + [Theory] + [InlineData(CaseHandling.ToLower, "user@example.com")] + [InlineData(CaseHandling.ToUpper, "USER@EXAMPLE.COM")] + public void Email_should_apply_case_policy( + CaseHandling policy, + string expected) + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.EmailCase = policy; + }); + + var result = normalizer.Normalize( + UserIdentifierType.Email, + "User@Example.COM"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be(expected); + } + + [Fact] + public void Email_should_preserve_case_when_policy_is_not_lower_or_upper() + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.EmailCase = (CaseHandling)999; + }); + + var result = normalizer.Normalize( + UserIdentifierType.Email, + "User@Example.COM"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("User@Example.COM"); + } + + [Theory] + [InlineData("+90 (555) 123-45-67", "+905551234567")] + [InlineData("0555 123 45 67", "05551234567")] + [InlineData("123-4567", "1234567")] + public void Phone_should_remove_formatting_characters( + string input, + string expected) + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Phone, + input); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be(expected); + } + + [Theory] + [InlineData("123456")] + [InlineData("abc")] + [InlineData("++")] + public void Phone_should_reject_short_normalized_values(string input) + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Phone, + input); + + result.IsValid.Should().BeFalse(); + result.ErrorCode.Should().Be("phone_invalid_length"); + } + + [Fact] + public void Phone_should_accept_seven_character_normalized_value() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Phone, + "1234567"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("1234567"); + } + + [Fact] + public void Phone_should_keep_plus_only_at_beginning_of_output() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Phone, + "12+34+567"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("1234567"); + } + + [Fact] + public void Phone_should_accept_unicode_digits() + { + var result = CreateNormalizer().Normalize( + UserIdentifierType.Phone, + "١٢٣٤٥٦٧"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be("١٢٣٤٥٦٧"); + } + + [Theory] + [InlineData(CaseHandling.ToLower, "customvalue")] + [InlineData(CaseHandling.ToUpper, "CUSTOMVALUE")] + public void Custom_identifier_should_apply_case_policy( + CaseHandling policy, + string expected) + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.CustomCase = policy; + }); + + var result = normalizer.Normalize( + (UserIdentifierType)999, + "CustomValue"); + + result.IsValid.Should().BeTrue(); + result.Normalized.Should().Be(expected); + } + + [Fact] + public void Custom_identifier_should_preserve_case_for_unknown_policy() + { + var normalizer = CreateNormalizer(o => + { + o.Identifiers.Normalization.CustomCase = (CaseHandling)999; + }); + + var result = normalizer.Normalize( + (UserIdentifierType)999, + "CustomValue"); + + result.Normalized.Should().Be("CustomValue"); + } + + [Fact] + public void Custom_identifier_should_reject_value_that_becomes_empty() + { + var result = CreateNormalizer().Normalize( + (UserIdentifierType)999, + "\u200B\u200C\u200D\uFEFF"); + + result.IsValid.Should().BeFalse(); + result.Normalized.Should().BeEmpty(); + result.ErrorCode.Should().Be("identifier_invalid"); + } + + private static IdentifierNormalizer CreateNormalizer( + Action? configure = null) + { + var options = new UAuthServerOptions(); + + configure?.Invoke(options); + + return new IdentifierNormalizer(Options.Create(options)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 25bedf8d..340a615b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Extensions; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Options; @@ -492,4 +493,438 @@ public void Pagination_default_page_size_equal_to_maximum_should_pass() options.Pagination.DefaultPageSize.Should().Be(100); options.Pagination.MaxPageSize.Should().Be(100); } + + [Theory] + [InlineData(-1)] + [InlineData(-10)] + public void Login_negative_max_attempts_fails(int attempts) => + ShouldReject(o => o.Login.MaxFailedAttempts = attempts, "Login.MaxFailedAttempts"); + + [Fact] + public void Login_negative_lockout_duration_fails() => + ShouldReject(o => o.Login.LockoutDuration = TimeSpan.FromTicks(-1), "Login.LockoutMinutes"); + + [Fact] + public void Login_zero_limits_are_accepted() => + ShouldAccept(o => + { + o.Login.MaxFailedAttempts = 0; + o.Login.LockoutDuration = TimeSpan.Zero; + }); + + [Fact] + public void Login_multiple_errors_are_reported() => + ShouldReject(o => + { + o.Login.MaxFailedAttempts = -1; + o.Login.LockoutDuration = TimeSpan.FromMinutes(-1); + }, "Login.LockoutMinutes"); + + [Theory] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + public void MultiTenant_disabled_with_any_resolver_fails(bool route, bool header, bool domain) => + ShouldReject(o => + { + o.MultiTenant.Enabled = false; + o.MultiTenant.EnableRoute = route; + o.MultiTenant.EnableHeader = header; + o.MultiTenant.EnableDomain = domain; + }, "Multi-tenancy is disabled"); + + [Fact] + public void MultiTenant_disabled_without_resolvers_passes() => + ShouldAccept(o => + { + o.MultiTenant.Enabled = false; + o.MultiTenant.EnableRoute = false; + o.MultiTenant.EnableHeader = false; + o.MultiTenant.EnableDomain = false; + }); + + [Theory] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + public void MultiTenant_enabled_with_any_resolver_passes(bool route, bool header, bool domain) => + ShouldAccept(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableRoute = route; + o.MultiTenant.EnableHeader = header; + o.MultiTenant.EnableDomain = domain; + if (header) o.MultiTenant.HeaderName = "X-Tenant"; + }); + + [Fact] + public void MultiTenant_header_whitespace_fails() => + ShouldReject(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableHeader = true; + o.MultiTenant.HeaderName = " "; + }, "MultiTenant.HeaderName"); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Pkce_nonpositive_authorization_lifetime_fails(int seconds) => + ShouldReject(o => o.Pkce.AuthorizationCodeLifetimeSeconds = seconds, + "Pkce.AuthorizationCodeLifetimeSeconds"); + + [Fact] + public void Pkce_positive_authorization_lifetime_passes() => + ShouldAccept(o => o.Pkce.AuthorizationCodeLifetimeSeconds = 1); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Session_nonpositive_lifetime_fails(int ticks) => + ShouldReject(o => o.Session.Lifetime = TimeSpan.FromTicks(ticks), + "Session.Lifetime"); + + [Fact] + public void Session_nonpositive_max_lifetime_fails() => + ShouldReject(o => o.Session.MaxLifetime = TimeSpan.Zero, + "Session.MaxLifetime"); + + [Fact] + public void Session_max_lifetime_less_than_lifetime_fails() => + ShouldReject(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(30); + o.Session.MaxLifetime = TimeSpan.FromMinutes(29); + }, "Session.MaxLifetime"); + + [Fact] + public void Session_equal_max_lifetime_passes() => + ShouldAccept(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(30); + o.Session.MaxLifetime = TimeSpan.FromMinutes(30); + o.Session.IdleTimeout = TimeSpan.Zero; + }); + + [Fact] + public void Session_null_optional_timeouts_pass() => + ShouldAccept(o => + { + o.Session.MaxLifetime = null; + o.Session.IdleTimeout = null; + }); + + [Fact] + public void Token_negative_concurrent_refresh_window_fails() => + ShouldReject(o => + o.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromTicks(-1), + "RefreshTokenConcurrentRequestWindow"); + + [Fact] + public void Token_no_issuance_fails_under_current_validator() => + ShouldReject(o => + { + o.Token.IssueJwt = false; + o.Token.IssueOpaque = false; + }, "IssueJwt or IssueOpaque"); + + [Fact] + public void Token_nonpositive_access_lifetime_fails() => + ShouldReject(o => o.Token.AccessTokenLifetime = TimeSpan.Zero, + "AccessTokenLifetime"); + + [Fact] + public void Token_nonpositive_refresh_lifetime_fails_when_enabled() => + ShouldReject(o => + { + o.Token.IssueRefresh = true; + o.Token.RefreshTokenLifetime = TimeSpan.Zero; + }, "RefreshTokenLifetime"); + + [Fact] + public void Token_refresh_lifetime_equal_to_access_lifetime_fails() => + ShouldReject(o => + { + o.Token.IssueRefresh = true; + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10); + o.Token.RefreshTokenLifetime = TimeSpan.FromMinutes(10); + }, "RefreshTokenLifetime"); + + [Theory] + [InlineData(true, " ", "UAuthClient", "Token.Issuer")] + [InlineData(true, "ab", "UAuthClient", "Token.Issuer")] + [InlineData(true, "UAuth", " ", "Token.Audience")] + [InlineData(true, "UAuth", "ab", "Token.Audience")] + public void Token_invalid_jwt_identifiers_fail(bool issueJwt, string issuer, string audience, string expected) => + ShouldReject(o => + { + o.Token.IssueJwt = issueJwt; + o.Token.Issuer = issuer; + o.Token.Audience = audience; + }, expected); + + [Theory] + [InlineData(0)] + [InlineData(15)] + [InlineData(129)] + public void Token_opaque_entropy_outside_limits_fails(int bytes) => + ShouldReject(o => + { + o.Token.IssueOpaque = true; + o.Token.OpaqueIdBytes = bytes; + }, "OpaqueIdBytes"); + + [Theory] + [InlineData(16)] + [InlineData(128)] + public void Token_opaque_entropy_boundary_values_pass(int bytes) => + ShouldAccept(o => + { + o.Token.IssueOpaque = true; + o.Token.OpaqueIdBytes = bytes; + }); + + [Fact] + public void Token_jwt_fields_are_not_required_when_jwt_disabled() => + ShouldAccept(o => + { + o.Token.IssueJwt = false; + o.Token.IssueOpaque = true; + o.Token.Issuer = ""; + o.Token.Audience = ""; + }); + + [Fact] + public void Token_refresh_lifetime_is_not_checked_when_refresh_disabled() => + ShouldAccept(o => + { + o.Token.IssueRefresh = false; + o.Token.RefreshTokenLifetime = TimeSpan.Zero; + }); + + [Fact] + public void Token_multiple_errors_are_reported() => + ShouldReject(o => + { + o.Token.AccessTokenLifetime = TimeSpan.Zero; + o.Token.OpaqueIdBytes = 1; + }, "OpaqueIdBytes"); + + [Fact] + public void Identifiers_only_user_override_passes() => + ShouldAccept(o => + { + o.Identifiers.Behavior.AllowAdminOverride = false; + o.Identifiers.Behavior.AllowUserOverride = true; + }); + + [Fact] + public void Identifiers_both_overrides_enabled_pass() => + ShouldAccept(o => + { + o.Identifiers.Behavior.AllowAdminOverride = true; + o.Identifiers.Behavior.AllowUserOverride = true; + }); + + [Fact] + public void SessionResolution_empty_order_fails() => + ShouldReject(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.Order = new(); + }, "SessionResolution.Order"); + + [Fact] + public void SessionResolution_unknown_resolver_fails() => + ShouldReject(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.Order = new() { "Bearer", "Unknown" }; + }, "Unknown session resolver"); + + [Fact] + public void SessionResolution_query_without_parameter_name_fails() => + ShouldReject(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.EnableQuery = true; + o.SessionResolution.Order = new() { "Bearer", "Query" }; + o.SessionResolution.QueryParameterName = " "; + }, "QueryParameterName"); + + [Theory] + [InlineData("Bearer")] + [InlineData("Header")] + [InlineData("Cookie")] + [InlineData("Query")] + public void SessionResolution_enabled_resolver_names_are_case_insensitive(string resolver) => + ShouldAccept(o => + { + o.SessionResolution.EnableBearer = resolver == "Bearer"; + o.SessionResolution.EnableHeader = resolver == "Header"; + o.SessionResolution.EnableCookie = resolver == "Cookie"; + o.SessionResolution.EnableQuery = resolver == "Query"; + o.SessionResolution.HeaderName = "X-Session"; + o.SessionResolution.QueryParameterName = "session"; + o.SessionResolution.Order = new() { resolver.ToLowerInvariant() }; + }); + + [Fact] + public void Pagination_null_configuration_fails() => + ShouldReject(o => o.Pagination = null!, + "Pagination configuration cannot be null"); + + [Fact] + public void Pagination_multiple_errors_are_reported() => + ShouldReject(o => + { + o.Pagination.DefaultPageSize = -1; + o.Pagination.MaxPageSize = 0; + }, "Pagination.MaxPageSize"); + + private static void ConfigureValidCrossOptions(UAuthServerOptions o) + { + o.AllowedModes = new[] { UAuthMode.Hybrid }; + o.Session.Lifetime = TimeSpan.FromDays(7); + o.Session.MaxLifetime = TimeSpan.FromDays(10); + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10); + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); + } + + [Fact] + public void Server_base_path_missing_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Endpoints.BasePath = " "; + }, "BasePath must be specified"); + + [Fact] + public void Server_base_path_double_slash_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Endpoints.BasePath = "/auth//api"; + }, "BasePath cannot contain"); + + [Fact] + public void Server_undefined_mode_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.AllowedModes = new[] { (UAuthMode)999 }; + }, "Invalid UAuthMode"); + + [Theory] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public void Server_unimplemented_mode_fails(UAuthMode mode) => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.AllowedModes = new[] { mode }; + }, "not implemented yet"); + + [Theory] + [InlineData(UAuthMode.Hybrid)] + [InlineData(UAuthMode.PureOpaque)] + public void Server_implemented_modes_pass(UAuthMode mode) => + ShouldAccept(o => + { + ConfigureValidCrossOptions(o); + o.AllowedModes = new[] { mode }; + }); + + [Fact] + public void Server_null_allowed_modes_pass_under_current_rules() => + ShouldAccept(o => + { + ConfigureValidCrossOptions(o); + o.AllowedModes = null; + }); + + [Fact] + public void Server_empty_allowed_modes_pass_under_current_rules() => + ShouldAccept(o => + { + ConfigureValidCrossOptions(o); + o.AllowedModes = Array.Empty(); + }); + + [Fact] + public void Server_session_lifetime_nonpositive_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Session.Lifetime = TimeSpan.Zero; + }, "Session.Lifetime"); + + [Fact] + public void Server_session_max_lifetime_nonpositive_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Session.MaxLifetime = TimeSpan.Zero; + }, "Session.MaxLifetime"); + + [Fact] + public void Server_access_token_exceeding_session_max_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Session.MaxLifetime = TimeSpan.FromMinutes(5); + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(6); + o.Token.RefreshTokenLifetime = TimeSpan.FromMinutes(4); + }, "Token.AccessTokenLifetime"); + + [Fact] + public void Server_refresh_token_exceeding_session_max_fails() => + ShouldReject(o => + { + ConfigureValidCrossOptions(o); + o.Session.MaxLifetime = TimeSpan.FromMinutes(5); + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(1); + o.Token.RefreshTokenLifetime = TimeSpan.FromMinutes(6); + }, "Token.RefreshTokenLifetime"); + + [Fact] + public void Server_token_lifetimes_equal_to_session_max_pass() => + ShouldAccept(o => + { + ConfigureValidCrossOptions(o); + o.Session.MaxLifetime = TimeSpan.FromDays(7); + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); + }); + + [Fact] + public void Server_null_session_max_lifetime_passes_under_current_rules() => + ShouldAccept(o => + { + ConfigureValidCrossOptions(o); + o.Session.MaxLifetime = null; + }); + + + private static void ShouldReject(Action configure, string expected) + where TValidator : class, IValidateOptions, new() + { + var services = new ServiceCollection(); + services.AddOptions().Configure(configure); + services.AddSingleton, TValidator>(); + using var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws(() => + _ = provider.GetRequiredService>().Value); + Assert.Contains(expected, ex.Message, StringComparison.OrdinalIgnoreCase); + } + + private static void ShouldAccept(Action configure) + where TValidator : class, IValidateOptions, new() + { + var services = new ServiceCollection(); + services.AddOptions().Configure(configure); + services.AddSingleton, TValidator>(); + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService>().Value; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthServerProductInfoTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthServerProductInfoTests.cs new file mode 100644 index 00000000..ed7b7e05 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthServerProductInfoTests.cs @@ -0,0 +1,135 @@ +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Runtime; +using FluentAssertions; +using Microsoft.Extensions.Options; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthServerProductInfoTests +{ + [Fact] + public void ProductInfo_should_have_expected_defaults() + { + var info = new UAuthServerProductInfo(); + + info.ProductName.Should().Be("UltimateAuth Server"); + info.RuntimeId.Should().NotBeNullOrWhiteSpace(); + + Guid.TryParseExact(info.RuntimeId, "N", out _) + .Should().BeTrue(); + } + + [Fact] + public void ProductInfo_should_generate_unique_runtime_ids() + { + var first = new UAuthServerProductInfo(); + var second = new UAuthServerProductInfo(); + + first.RuntimeId.Should().NotBe(second.RuntimeId); + } + + [Theory] + [InlineData(UAuthHubDeploymentMode.Integrated, false)] + [InlineData(UAuthHubDeploymentMode.Integrated, true)] + public void Provider_should_reflect_server_options( + UAuthHubDeploymentMode deploymentMode, + bool multiTenancyEnabled) + { + var options = new UAuthServerOptions + { + HubDeploymentMode = deploymentMode + }; + + options.MultiTenant.Enabled = multiTenancyEnabled; + + var provider = new UAuthServerProductInfoProvider( + Options.Create(options)); + + var info = provider.Get(); + + info.HubDeploymentMode.Should().Be(deploymentMode); + info.MultiTenancyEnabled.Should().Be(multiTenancyEnabled); + } + + [Fact] + public void Provider_should_return_correct_assembly_metadata() + { + var provider = CreateProvider(); + + var info = provider.Get(); + + var assembly = typeof(UAuthServerProductInfoProvider).Assembly; + + var expectedVersion = + assembly.GetName().Version?.ToString(3) ?? "unknown"; + + var expectedInformationalVersion = + assembly.GetCustomAttribute() + ?.InformationalVersion; + + info.ProductName.Should().Be("UltimateAuth Server"); + info.Version.Should().Be(expectedVersion); + info.InformationalVersion.Should().Be(expectedInformationalVersion); + info.FrameworkDescription.Should().Be( + RuntimeInformation.FrameworkDescription); + } + + [Fact] + public void Provider_should_capture_creation_time() + { + var before = DateTimeOffset.UtcNow; + + var provider = CreateProvider(); + + var after = DateTimeOffset.UtcNow; + var info = provider.Get(); + + info.StartedAt.Should().BeOnOrAfter(before); + info.StartedAt.Should().BeOnOrBefore(after); + } + + [Fact] + public void Provider_should_return_same_info_instance() + { + var provider = CreateProvider(); + + var first = provider.Get(); + var second = provider.Get(); + + first.Should().BeSameAs(second); + first.RuntimeId.Should().Be(second.RuntimeId); + first.StartedAt.Should().Be(second.StartedAt); + } + + [Fact] + public void Different_providers_should_have_different_runtime_ids() + { + var first = CreateProvider().Get(); + var second = CreateProvider().Get(); + + first.Should().NotBeSameAs(second); + first.RuntimeId.Should().NotBe(second.RuntimeId); + } + + [Fact] + public void Provider_should_snapshot_options_at_construction() + { + var options = new UAuthServerOptions(); + options.MultiTenant.Enabled = false; + + var provider = new UAuthServerProductInfoProvider( + Options.Create(options)); + + options.MultiTenant.Enabled = true; + + provider.Get().MultiTenancyEnabled.Should().BeFalse(); + } + + private static UAuthServerProductInfoProvider CreateProvider() + { + return new UAuthServerProductInfoProvider( + Options.Create(new UAuthServerOptions())); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateCredentialResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateCredentialResolverTests.cs new file mode 100644 index 00000000..16a7195f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateCredentialResolverTests.cs @@ -0,0 +1,316 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ValidateCredentialResolverTests +{ + [Fact] + public async Task Session_cookie_should_resolve_and_trim_value() + { + var context = CreateContext(); + context.Request.Headers.Cookie = "uauth-session=%20session-123%20"; + + var resolver = CreateResolver(PrimaryTokenKind.Session); + var response = CreateResponse( + session: CookieDelivery("uauth-session")); + + var result = await resolver.ResolveAsync(context, response); + + result.Should().NotBeNull(); + result!.Kind.Should().Be(PrimaryTokenKind.Session); + result.Value.Should().Be("session-123"); + result.Tenant.Should().Be(context.GetTenant()); + result.Device.Should().NotBeNull(); + } + + [Theory] + [InlineData(TokenResponseMode.None)] + [InlineData(TokenResponseMode.Header)] + [InlineData(TokenResponseMode.Body)] + public async Task Session_should_require_cookie_delivery( + TokenResponseMode mode) + { + var context = CreateContext(); + context.Request.Headers.Cookie = "uauth-session=session-123"; + + var resolver = CreateResolver(PrimaryTokenKind.Session); + var response = CreateResponse( + session: new CredentialResponseOptions + { + Mode = mode + }); + + var result = await resolver.ResolveAsync(context, response); + + result.Should().BeNull(); + } + + [Fact] + public async Task Session_should_require_cookie_options() + { + var context = CreateContext(); + var resolver = CreateResolver(PrimaryTokenKind.Session); + + var response = CreateResponse( + session: new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie + }); + + var result = await resolver.ResolveAsync(context, response); + + result.Should().BeNull(); + } + + [Fact] + public async Task Session_should_return_null_when_cookie_missing() + { + var context = CreateContext(); + var resolver = CreateResolver(PrimaryTokenKind.Session); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(session: CookieDelivery("uauth-session"))); + + result.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public async Task Session_should_reject_blank_cookie(string value) + { + var context = CreateContext(); + context.Request.Headers.Cookie = + $"uauth-session={Uri.EscapeDataString(value)}"; + + var resolver = CreateResolver(PrimaryTokenKind.Session); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(session: CookieDelivery("uauth-session"))); + + result.Should().BeNull(); + } + + [Fact] + public async Task Access_token_should_resolve_bearer_header() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer access-123"; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery())); + + result.Should().NotBeNull(); + result!.Kind.Should().Be(PrimaryTokenKind.AccessToken); + result.Value.Should().Be("access-123"); + result.Tenant.Should().Be(context.GetTenant()); + result.Device.Should().NotBeNull(); + } + + [Fact] + public async Task Access_token_should_accept_case_insensitive_bearer_prefix() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "bEaReR access-123"; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery())); + + result!.Value.Should().Be("access-123"); + } + + [Fact] + public async Task Access_token_should_support_custom_header() + { + var context = CreateContext(); + context.Request.Headers["X-Access-Token"] = "access-123"; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery( + "X-Access-Token", + HeaderTokenFormat.Raw))); + + result!.Value.Should().Be("access-123"); + } + + [Fact] + public async Task Raw_header_should_preserve_bearer_text() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer access-123"; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery( + format: HeaderTokenFormat.Raw))); + + result!.Value.Should().Be("Bearer access-123"); + } + + [Theory] + [InlineData(TokenResponseMode.None)] + [InlineData(TokenResponseMode.Cookie)] + [InlineData(TokenResponseMode.Body)] + public async Task Access_token_should_require_header_delivery( + TokenResponseMode mode) + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer access-123"; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: new CredentialResponseOptions + { + Mode = mode + })); + + result.Should().BeNull(); + } + + [Fact] + public async Task Access_token_should_return_null_when_header_missing() + { + var context = CreateContext(); + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery())); + + result.Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Bearer ")] + [InlineData("Bearer ")] + public async Task Access_token_should_reject_blank_values(string value) + { + var context = CreateContext(); + context.Request.Headers.Authorization = value; + + var resolver = CreateResolver(PrimaryTokenKind.AccessToken); + + var result = await resolver.ResolveAsync( + context, + CreateResponse(access: HeaderDelivery())); + + result.Should().BeNull(); + } + + [Fact] + public async Task Unsupported_primary_token_kind_should_return_null() + { + var context = CreateContext(); + var resolver = CreateResolver((PrimaryTokenKind)999); + + var result = await resolver.ResolveAsync( + context, + CreateResponse()); + + result.Should().BeNull(); + } + + private static HttpContext CreateContext() + { + var context = TestHttpContext.Create(); + + var services = new ServiceCollection(); + services.AddSingleton( + new FakeDeviceResolver()); + + context.RequestServices = services.BuildServiceProvider(); + + return context; + } + + private static ValidateCredentialResolver CreateResolver( + PrimaryTokenKind kind) + { + return new ValidateCredentialResolver( + new FixedPrimaryCredentialResolver(kind)); + } + + private static CredentialResponseOptions CookieDelivery(string name) + { + return new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie + }.WithCookie(new UAuthCookieOptions + { + Name = name + }); + } + + private static CredentialResponseOptions HeaderDelivery( + string? name = null, + HeaderTokenFormat format = HeaderTokenFormat.Bearer) + { + return new CredentialResponseOptions + { + Mode = TokenResponseMode.Header, + Name = name, + HeaderFormat = format + }; + } + + private static EffectiveAuthResponse CreateResponse( + CredentialResponseOptions? session = null, + CredentialResponseOptions? access = null) + { + return new EffectiveAuthResponse( + session ?? CredentialResponseOptions.Disabled(GrantKind.Session), + access ?? CredentialResponseOptions.Disabled(GrantKind.AccessToken), + CredentialResponseOptions.Disabled(GrantKind.RefreshToken), + EffectiveRedirectResponse.Disabled); + } + + private sealed class FixedPrimaryCredentialResolver + : IPrimaryCredentialResolver + { + private readonly PrimaryTokenKind _kind; + + public FixedPrimaryCredentialResolver(PrimaryTokenKind kind) + { + _kind = kind; + } + + public PrimaryTokenKind Resolve(HttpContext context) => _kind; + } + + private sealed class FakeDeviceResolver : IDeviceResolver + { + public Task ResolveAsync(HttpContext context) + { + return Task.FromResult(TestDevice.DefaultDeviceInfo()); + } + } +} From c71fb9a9459416b6398a79aa7232e76394955e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 00:54:03 +0300 Subject: [PATCH 25/26] Fix Warnings --- .../Components/Pages/ResetCredential.razor.cs | 8 +++++++- .../Components/Pages/ResetCredential.razor.cs | 8 +++++++- .../Pages/ResetCredential.razor.cs | 8 +++++++- .../Pages/ResetCredential.razor.cs | 10 ++++++++-- .../Server/UAuthResourceAccessOrchestratorTests.cs | 2 +- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs index 2cd5e204..9c453a4d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs @@ -25,10 +25,16 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, + NewSecret = _newPassword, Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs index 681ff884..f02691c1 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -25,10 +25,16 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, + NewSecret = _newPassword, Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs index 43e7b541..231c8115 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs @@ -25,10 +25,16 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, + NewSecret = _newPassword, Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs index be79c63a..401d8656 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs @@ -25,11 +25,17 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, - Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + NewSecret = _newPassword, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index 17f2f0b2..9edd39a6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -136,7 +136,7 @@ public async Task ExecuteAsync_WhenDeniedWithoutReason_ShouldUseDefaultReason() .Setup(x => x.Decide( It.IsAny(), policies)) - .Returns(AccessDecision.Deny(null)); + .Returns(AccessDecision.Deny(string.Empty)); var command = new AccessCommand(_ => Task.CompletedTask); From 19548d4213ab28e273d9ed3742bbcf74ee046965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 11 Oct 2026 01:03:53 +0300 Subject: [PATCH 26/26] Fix Test --- .../Contracts/Authority/AccessDecision.cs | 2 +- .../Server/UAuthResourceAccessOrchestratorTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs index fa89076e..d0b27ae9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -19,7 +19,7 @@ public static AccessDecision Allow() requiresReauthentication: false, denyReason: null); - public static AccessDecision Deny(string reason) + public static AccessDecision Deny(string? reason) => new( isAllowed: false, requiresReauthentication: false, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index 9edd39a6..17f2f0b2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -136,7 +136,7 @@ public async Task ExecuteAsync_WhenDeniedWithoutReason_ShouldUseDefaultReason() .Setup(x => x.Decide( It.IsAny(), policies)) - .Returns(AccessDecision.Deny(string.Empty)); + .Returns(AccessDecision.Deny(null)); var command = new AccessCommand(_ => Task.CompletedTask);