From 49c178b693a4bcb8f3560f4c9ed055e35af7f0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:40:15 +0300 Subject: [PATCH 01/20] Fix Credential Reset Code Can be Accessible On Client With HTTP Result (#64) --- .../Components/Dialogs/ResetDialog.razor | 3 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 - .../Components/Dialogs/ProfileDialog.razor | 4 +- .../Components/Dialogs/ResetDialog.razor | 14 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 - .../DevelopmentCredentialResetNotifier.cs | 21 ++ .../Program.cs | 6 + .../Components/Dialogs/ResetDialog.razor | 3 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 - .../Components/Dialogs/ResetDialog.razor | 3 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 - .../AssemblyVisibility.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../NotConfiguredCredentialResetNotifier.cs | 20 ++ .../Responses/BeginCredentialResetResult.cs | 1 - .../Services/CredentialManagementService.cs | 41 ++- .../Abstractions/ICredentialResetNotifier.cs | 17 + .../CredentialManagementServiceTests.cs | 5 + .../Credentials/ResetPasswordTests.cs | 333 ++++++++++-------- .../Helpers/TestAuthRuntime.cs | 4 +- 20 files changed, 293 insertions(+), 192 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs create mode 100644 src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs index a819f039..f70034d2 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor index d09fcfa0..26863c14 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -1,6 +1,4 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor index 06a515aa..4809f4f7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -1,7 +1,4 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Credentials.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IUAuthStateManager StateManager @@ -15,8 +12,8 @@ - This is a demonstration of how to implement a credential reset flow. - In a production application, you should use reset token or code in email, SMS etc. verification steps. + In this development sample, reset codes are written to the server console by a custom ICredentialResetNotifier. + Production applications should send them through a trusted email or SMS channel. Reset request always returns ok even with not found users due to security reasons. @@ -25,9 +22,8 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode - Use Reset Code + Your reset code is on the server console. + Use Reset Code } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs index 9ca66fc3..96e81a55 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs new file mode 100644 index 00000000..db379e8f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentCredentialResetNotifier.cs @@ -0,0 +1,21 @@ +using CodeBeam.UltimateAuth.Credentials; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; + +internal sealed class DevelopmentCredentialResetNotifier(ILogger logger) : ICredentialResetNotifier +{ + public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + logger.LogWarning( + "DEVELOPMENT ONLY — Credential reset for tenant {Tenant}, " + + "user {UserKey}. Code: {Code}. Expires at: {ExpiresAt}", + notification.Tenant, + notification.UserKey, + notification.Token, + notification.ExpiresAt); + + return Task.CompletedTask; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index b939d487..fb79b4a0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; @@ -63,6 +64,11 @@ builder.Services.AddUltimateAuthSampleSeed(); +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddScoped(); +} + builder.Services.Configure(options => { options.ForwardedHeaders = diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs index 977d1e38..7dab5f66 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor index 06a515aa..8d4c90d3 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor @@ -25,8 +25,7 @@ Request Reset @if (_resetRequested) { - Your reset code is: (Copy it before next step) - @_resetCode + Your reset code is on the server console. Use Reset Code } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs index 04657723..17ff4866 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs @@ -9,7 +9,6 @@ namespace CodeBeam.UAuth.Sample.IntWasm.Client.Components.Dialogs; public partial class ResetDialog { private bool _resetRequested = false; - private string? _resetCode; private string? _identifier; [CascadingParameter] @@ -34,7 +33,6 @@ private async Task RequestResetAsync() return; } - _resetCode = result.Value.Token; _resetRequested = true; } diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs index d3c09386..247b2327 100644 --- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.Reference")] [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 690e5e98..dfa550da 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -358,6 +358,7 @@ internal static IServiceCollection AddUsersInternal(IServiceCollection services) internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) { services.TryAddScoped(); + services.TryAddScoped(); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs new file mode 100644 index 00000000..2215e023 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/NotConfiguredCredentialResetNotifier.cs @@ -0,0 +1,20 @@ +using CodeBeam.UltimateAuth.Credentials; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class NotConfiguredCredentialResetNotifier : ICredentialResetNotifier +{ + internal static InvalidOperationException CreateException() => new( + "Credential reset notification is not configured. " + + "Implement ICredentialResetNotifier and register it with " + + "services.AddScoped(). " + + "The implementation must send the reset code or token " + + "through a trusted channel associated with the user's account."); + + public Task NotifyAsync(CredentialResetNotification context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw CreateException(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs index c7f399c8..db8d2c8c 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs @@ -2,6 +2,5 @@ public sealed record BeginCredentialResetResult { - public string? Token { get; init; } public DateTimeOffset ExpiresAt { 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 71d051d6..95735d6e 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -24,6 +24,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ITokenHasher _tokenHasher; private readonly ILoginIdentifierResolver _identifierResolver; private readonly ISessionStoreFactory _sessionFactory; + private readonly ICredentialResetNotifier _resetNotifier; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -37,6 +38,7 @@ public CredentialManagementService( ITokenHasher tokenHasher, ILoginIdentifierResolver identifierResolver, ISessionStoreFactory sessionFactory, + ICredentialResetNotifier resetNotifier, IOptions options, IClock clock) { @@ -49,6 +51,7 @@ public CredentialManagementService( _tokenHasher = tokenHasher; _identifierResolver = identifierResolver; _sessionFactory = sessionFactory; + _resetNotifier = resetNotifier; _options = options.Value; _clock = clock; } @@ -199,22 +202,28 @@ public async Task BeginResetAsync(AccessContext cont var cmd = new AccessCommand(async innerCt => { + + if (_resetNotifier is NotConfiguredCredentialResetNotifier) + throw NotConfiguredCredentialResetNotifier.CreateException(); + if (string.IsNullOrWhiteSpace(request.Identifier)) throw new UAuthValidationException("identifier_required"); var now = _clock.UtcNow; - var validity = request.Validity ?? _options.ResetCredential.TokenValidity; + // Anonymous callers must not control the proof's lifetime. + var validity = _options.ResetCredential.TokenValidity; + var expiresAt = now.Add(validity); + + var result = new BeginCredentialResetResult + { + ExpiresAt = expiresAt + }; + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); if (resolution?.UserKey is not UserKey userKey) - { - return new BeginCredentialResetResult - { - Token = null, - ExpiresAt = now.Add(validity) - }; - } + return result; // Enumeration protection var state = await _authenticationSecurityManager .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); @@ -239,11 +248,17 @@ public async Task BeginResetAsync(AccessContext cont var updatedState = state.BeginReset(tokenHash, now, validity); await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt); - return new BeginCredentialResetResult - { - Token = token, - ExpiresAt = now.Add(validity) - }; + await _resetNotifier.NotifyAsync( + new CredentialResetNotification( + Tenant: context.ResourceTenant, + UserKey: userKey, + CredentialType: request.CredentialType, + CodeType: request.ResetCodeType, + Token: token, + ExpiresAt: expiresAt), + innerCt); + + return result; }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs new file mode 100644 index 00000000..d6ca6c3c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialResetNotifier.cs @@ -0,0 +1,17 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialResetNotifier +{ + Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default); +} + +public sealed record CredentialResetNotification( + TenantKey Tenant, + UserKey UserKey, + CredentialType CredentialType, + ResetCodeType CodeType, + string Token, + DateTimeOffset ExpiresAt); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 601b268e..b6f2bf97 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -5,6 +5,7 @@ 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.Credentials.Reference.Internal; @@ -746,6 +747,9 @@ private sealed class Fixture public Mock IdentifierResolver { get; } = new(MockBehavior.Strict); + public Mock ResetNotifier { get; } + = new(MockBehavior.Strict); + public Mock SessionStoreFactory { get; } = new(MockBehavior.Strict); @@ -830,6 +834,7 @@ public Fixture() TokenHasher.Object, IdentifierResolver.Object, SessionStoreFactory.Object, + ResetNotifier.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index fced32ec..99785fda 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -1,98 +1,106 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; public class ResetPasswordTests { [Fact] - public async Task Begin_reset_with_token_should_return_token() + public async Task Begin_reset_with_token_should_notify_without_exposing_token() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + var result = await service.BeginResetAsync( + BeginContext(), + BeginRequest(ResetCodeType.Token)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var notification = notifier.Notifications.Should() + .ContainSingle().Subject; - result.Token.Should().NotBeNull(); - result.Token!.Length.Should().BeGreaterThan(20); - result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); - } + notification.Token.Should().NotBeNullOrWhiteSpace(); + notification.Token.Length.Should().BeGreaterThan(20); + notification.CodeType.Should().Be(ResetCodeType.Token); + notification.CredentialType.Should().Be(CredentialType.Password); + notification.ExpiresAt.Should().Be(result.ExpiresAt); + result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow); - [Fact] - public async Task Begin_reset_with_code_should_return_numeric_code() - { - var runtime = new TestAuthRuntime(); - var service = runtime.GetCredentialManagementService(); + // The public JSON response must not contain the reset proof. + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + + json.RootElement.TryGetProperty("token", out _).Should().BeFalse(); + JsonSerializer.Serialize(result) + .Should().NotContain(notification.Token); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + var json1 = JsonSerializer.Serialize(result, new JsonSerializerOptions(JsonSerializerDefaults.Web)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Code - }); + using var document = JsonDocument.Parse(json1); - result.Token.Should().NotBeNull(); - result.Token!.Should().MatchRegex("^[0-9]{6}$"); + document.RootElement + .EnumerateObject() + .Select(property => property.Name) + .Should() + .BeEquivalentTo( + new[] { "expiresAt" }, + "public reset response must contain only the approved fields"); + + json1.Should().NotContain( + notification.Token, + "the reset proof must be delivered only through the notifier"); } [Fact] - public async Task Begin_reset_for_unknown_user_should_not_fail() + public async Task Begin_reset_with_code_should_notify_numeric_code() { - var runtime = new TestAuthRuntime(); - var service = runtime.GetCredentialManagementService(); + var (runtime, notifier) = CreateRuntime(); - var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + await runtime.GetCredentialManagementService().BeginResetAsync( + BeginContext(), + BeginRequest(ResetCodeType.Code)); - var result = await service.BeginResetAsync(context, - new BeginResetCredentialRequest - { - Identifier = "unknown@test.com", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var notification = notifier.Notifications.Should() + .ContainSingle().Subject; + + notification.CodeType.Should().Be(ResetCodeType.Code); + notification.Token.Should().MatchRegex("^[0-9]{6}$"); + } - result.Token.Should().BeNull(); + [Fact] + public async Task Begin_reset_for_unknown_user_should_not_notify() + { + var (runtime, notifier) = CreateRuntime(); + + var result = await runtime.GetCredentialManagementService() + .BeginResetAsync( + BeginContext(), + BeginRequest( + ResetCodeType.Token, + "unknown@test.com")); + + result.Should().NotBeNull(); + result.ExpiresAt.Should().BeAfter(runtime.Clock.UtcNow); + notifier.Notifications.Should().BeEmpty(); } [Fact] public async Task Reset_password_with_valid_token_should_succeed() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); var result = await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); result.Succeeded.Should().BeTrue(); } @@ -100,28 +108,16 @@ public async Task Reset_password_with_valid_token_should_succeed() [Fact] public async Task Reset_password_with_same_password_should_fail() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "admin" - }); + CompleteContext(), + CompleteRequest(token, "admin")); await act.Should().ThrowAsync(); } @@ -129,45 +125,31 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_lock_after_max_attempts() { - var runtime = new TestAuthRuntime(); + const int maxAttempts = 3; + var (runtime, notifier) = CreateRuntime(maxAttempts); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Code - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Code); + + var wrongToken = token == "000000" ? "000001" : "000000"; - for (int i = 0; i < 3; i++) + for (var i = 0; i < maxAttempts; i++) { - try - { + Func invalidAttempt = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin!.Token == "000000" ? "000001" : "000000", - NewSecret = "newpass123" - }); - } - catch { } + CompleteContext(), + CompleteRequest(wrongToken, "newpass123")); + + // Do not swallow unrelated failures. + await invalidAttempt.Should() + .ThrowAsync(); } Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); await act.Should().ThrowAsync(); } @@ -175,38 +157,22 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_be_single_use() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); - - await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); + + var first = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "newpass123")); + + first.Succeeded.Should().BeTrue(); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "anotherpass" - }); + CompleteContext(), + CompleteRequest(token, "anotherpass")); await act.Should().ThrowAsync(); } @@ -214,32 +180,97 @@ await service.CompleteResetAsync( [Fact] public async Task Reset_token_should_fail_if_expired() { - var runtime = new TestAuthRuntime(); + var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); - var clock = runtime.Clock; - var begin = await service.BeginResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), - new BeginResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Token - }); + var token = await BeginAndCaptureAsync( + runtime, notifier, ResetCodeType.Token); + + var notification = notifier.Notifications.Single(); - clock.Advance(TimeSpan.FromMinutes(45)); + // Advance past the actual configured expiry. + runtime.Clock.Advance( + notification.ExpiresAt - runtime.Clock.UtcNow + + TimeSpan.FromSeconds(1)); Func act = async () => await service.CompleteResetAsync( - TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), - new CompleteResetCredentialRequest - { - Identifier = "admin", - CredentialType = CredentialType.Password, - ResetToken = begin.Token!, - NewSecret = "newpass123" - }); + CompleteContext(), + CompleteRequest(token, "newpass123")); await act.Should().ThrowAsync(); } + + private static ( + TestAuthRuntime Runtime, + RecordingCredentialResetNotifier Notifier) + CreateRuntime(int maxAttempts = 3) + { + var notifier = new RecordingCredentialResetNotifier(); + + var runtime = new TestAuthRuntime( + configureServer: options => + options.ResetCredential.MaxAttempts = maxAttempts, + configureServices: services => + services.AddSingleton(notifier)); + + return (runtime, notifier); + } + + private static async Task BeginAndCaptureAsync( + TestAuthRuntime runtime, + RecordingCredentialResetNotifier notifier, + ResetCodeType codeType) + { + await runtime.GetCredentialManagementService().BeginResetAsync( + BeginContext(), + BeginRequest(codeType)); + + return notifier.Notifications.Should() + .ContainSingle().Subject.Token; + } + + private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext + BeginContext() => + TestAccessContext.WithAction( + UAuthActions.Credentials.BeginResetAnonymous); + + private static CodeBeam.UltimateAuth.Core.Contracts.AccessContext + CompleteContext() => + TestAccessContext.WithAction( + UAuthActions.Credentials.CompleteResetAnonymous); + + private static BeginResetCredentialRequest BeginRequest( + ResetCodeType codeType, + string identifier = "admin") => new() + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = codeType + }; + + private static CompleteResetCredentialRequest CompleteRequest( + string token, + string newSecret) => new() + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = token, + NewSecret = newSecret + }; + + private sealed class RecordingCredentialResetNotifier + : ICredentialResetNotifier + { + public List Notifications { get; } = []; + + public Task NotifyAsync( + CredentialResetNotification notification, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Notifications.Add(notification); + return Task.CompletedTask; + } + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index b602f544..272c7a99 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -28,7 +28,7 @@ internal sealed class TestAuthRuntime where TUserId : notnull public IServiceProvider Services { get; } public TestClock Clock { get; } - public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) + public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null, Action? configureServices = null) { Clock = new TestClock(); var services = new ServiceCollection(); @@ -53,6 +53,8 @@ public TestAuthRuntime(Action? configureServer = null, Actio services.AddSingleton(configuration); services.AddSingleton(Clock); + configureServices?.Invoke(services); + Services = services.BuildServiceProvider(); using (var scope = Services.CreateScope()) From 5a3ae8903cf380b2714676a64d3ba1caa0862dd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:32 +0300 Subject: [PATCH 02/20] Fix Identifier Ownership Checks and Verification Trust Boundaries (#65) * Fix UserIdentifier Mutation Can be Done Across Non Targeted Users * Fix Identifier Verifier Abuse --- .../Dialogs/IdentifierDialog.razor.cs | 17 +- ...DevelopmentBypassUserIdentifierVerifier.cs | 23 +++ .../Program.cs | 1 + .../UAuthExceptionHandlingExtensions.cs | 3 + .../Requests/VerifyUserIdentifierRequest.cs | 6 + .../Extensions/ServiceCollectonExtensions.cs | 2 + .../NotConfiguredUserIdentifierVerifier.cs | 18 ++ .../Services/UserApplicationService.cs | 63 ++++++- .../Abstractions/IUserIdentifierVerifier.cs | 24 +++ .../UserIdentifierTests.cs | 168 +++++++++++++++++- .../UserLifecycleTests.cs | 53 ++---- .../Users/UserApplicationServiceTests.cs | 26 ++- 12 files changed, 348 insertions(+), 56 deletions(-) create mode 100644 samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs index 1a2f0a76..77d8e254 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs @@ -190,10 +190,12 @@ private async Task VerifyAsync(Guid id) title: "Demo Verification", markupMessage: (MarkupString) """ - This is a demo action.

- In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. - This will only mark the identifier as verified in UltimateAuth. - """, + This Development sample bypasses ownership verification.

+ No email or SMS proof is checked. The identifier will be marked + as verified for demonstration purposes only.

+ Production applications must implement a real + IUserIdentifierVerifier. + """, yesText: "Verify", noText: "Cancel", options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); @@ -203,7 +205,12 @@ This will only mark the identifier as verified in UltimateAuth. return; } - VerifyUserIdentifierRequest request = new() { Id = id }; + VerifyUserIdentifierRequest request = new() + { + Id = id, + Proof = UserKey is null ? "demo-verification-bypass" : null + }; + UAuthResult result; if (UserKey is null) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs new file mode 100644 index 00000000..0a4e4f5b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DevelopmentBypassUserIdentifierVerifier.cs @@ -0,0 +1,23 @@ +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Hosting; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; + +// TODO: Replace this demo bypass with a real verification challenge flow. +// Never register this implementation in production. +internal sealed class DevelopmentBypassUserIdentifierVerifier(IHostEnvironment environment) : IUserIdentifierVerifier +{ + public const string DemoProof = "demo-verification-bypass"; + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!environment.IsDevelopment()) + { + throw new InvalidOperationException("Development verification bypass cannot run outside Development."); + } + + return Task.FromResult(string.Equals(context.Proof, DemoProof, StringComparison.Ordinal)); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index fb79b4a0..d26cbda0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -67,6 +67,7 @@ if (builder.Environment.IsDevelopment()) { builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.Configure(options => diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index 6dd8d03a..f2c6a3d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -54,6 +54,9 @@ private static int MapStatusCode(UAuthRuntimeException ex) => UAuthForbiddenException => StatusCodes.Status403Forbidden, UAuthNotFoundException => StatusCodes.Status404NotFound, UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, + UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, + UAuthIdentifierConflictException => StatusCodes.Status409Conflict, + UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, _ => StatusCodes.Status400BadRequest }; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs index 205c5460..5e5a6a86 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -3,4 +3,10 @@ public sealed record VerifyUserIdentifierRequest { public Guid Id { get; init; } + + /// + /// Required for self verification. + /// Not required for authorized admin verification. + /// + public string? Proof { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs index ea1a0174..35c99919 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -23,6 +23,8 @@ public static IServiceCollection AddUltimateAuthUsersReference(this IServiceColl services.TryAddScoped(); services.AddScoped(); + services.TryAddScoped(); + return services; } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs new file mode 100644 index 00000000..d4c3035a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/NotConfiguredUserIdentifierVerifier.cs @@ -0,0 +1,18 @@ +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class NotConfiguredUserIdentifierVerifier : IUserIdentifierVerifier +{ + internal static InvalidOperationException CreateException() => new( + "User identifier verification is not configured. " + + "Implement IUserIdentifierVerifier and register it with " + + "services.AddScoped(). " + + "The implementation must validate ownership proof for the " + + "specified tenant, user, identifier and current value."); + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw CreateException(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index eff28d65..674ac3b7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -1,14 +1,15 @@ 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.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.Extensions.Options; -using CodeBeam.UltimateAuth.Server.Services; namespace CodeBeam.UltimateAuth.Users.Reference; @@ -25,6 +26,7 @@ internal sealed class UserApplicationService : IUserApplicationService private readonly IIdentifierNormalizer _identifierNormalizer; private readonly IUserIdentifierAvailabilityService _identifierAvailabilityService; private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly IUserIdentifierVerifier _identifierVerifier; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -40,6 +42,7 @@ public UserApplicationService( IIdentifierNormalizer identifierNormalizer, IUserIdentifierAvailabilityService identifierAvailabilityService, ISessionStoreFactory sessionStoreFactory, + IUserIdentifierVerifier ıdentifierVerifier, IOptions options, IClock clock) { @@ -54,6 +57,7 @@ public UserApplicationService( _identifierNormalizer = identifierNormalizer; _identifierAvailabilityService = identifierAvailabilityService; _sessionStoreFactory = sessionStoreFactory; + _identifierVerifier = ıdentifierVerifier; _options = options.Value; _clock = clock; } @@ -74,6 +78,12 @@ public async Task CreateUserAsync(AccessContext context, Creat async atomicCt => { var now = _clock.UtcNow; + + var allowVerifiedContacts = context.IsSystemActor || + ( + context.IsAuthenticated && string.Equals(context.Action, UAuthActions.Users.CreateAdmin, StringComparison.Ordinal) + ); + var userKey = UserKey.New(); var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); @@ -125,7 +135,7 @@ await identifierStore.AddAsync( _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, now, true, - request.EmailVerified ? now : null), atomicCt); + allowVerifiedContacts && request.EmailVerified ? now : null), atomicCt); } if (!string.IsNullOrWhiteSpace(request.Phone)) @@ -140,7 +150,7 @@ await identifierStore.AddAsync( _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, now, true, - request.PhoneVerified ? now : null), atomicCt); + allowVerifiedContacts && request.PhoneVerified ? now : null), atomicCt); } foreach (var integration in _integrations) @@ -563,7 +573,7 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null || identifier.IsDeleted) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.Behavior.AllowUsernameChange) @@ -620,7 +630,7 @@ public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimar var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null || identifier.IsDeleted) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (identifier.IsPrimary) @@ -644,7 +654,7 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); if (!identifier.IsPrimary) @@ -676,16 +686,51 @@ public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPr public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var command = new AccessCommand(async innerCt => { + innerCt.ThrowIfCancellationRequested(); + EnsureOverrideAllowed(context); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); var expectedVersion = identifier.Version; + + if (context.IsSelfAction) + { + if (_identifierVerifier is NotConfiguredUserIdentifierVerifier) + throw NotConfiguredUserIdentifierVerifier.CreateException(); + + if (string.IsNullOrWhiteSpace(request.Proof)) + throw new UAuthIdentifierValidationException("identifier_verification_proof_required"); + + var verified = await _identifierVerifier.VerifyAsync( + new UserIdentifierVerificationContext( + Tenant: context.ResourceTenant, + UserKey: identifier.UserKey, + IdentifierId: identifier.Id, + Type: identifier.Type, + Value: identifier.Value, + Proof: request.Proof), + innerCt); + + if (!verified) + throw new UAuthIdentifierValidationException("identifier_verification_failed"); + } + + // TODO: Provide a built-in identifier verification challenge flow: + // issuance, hashed storage, expiry, attempt limits and notification. + // Bind each challenge to tenant, user, identifier and current value. + // + // TODO: Coordinate single-use proof consumption with persistence. + // A concurrent identifier change must not be marked verified + // using proof issued for the previous value. + identifier.MarkVerified(_clock.UtcNow); await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); }); @@ -701,7 +746,7 @@ public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIde var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); - if (identifier is null) + if (identifier is null || identifier.IsDeleted || identifier.UserKey != context.GetTargetUserKey()) throw new UAuthIdentifierNotFoundException("identifier_not_found"); var identifiers = await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs new file mode 100644 index 00000000..dc634806 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs @@ -0,0 +1,24 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +/// +/// Validates identifier ownership proof on the server. +/// +/// +/// Implementations must bind the proof to the tenant, user, +/// identifier and current value; enforce expiry, attempt limits +/// and single-use consumption. +/// +public interface IUserIdentifierVerifier +{ + Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default); +} + +public sealed record UserIdentifierVerificationContext( + TenantKey Tenant, + UserKey UserKey, + Guid IdentifierId, + UserIdentifierType Type, + string Value, + string Proof); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs index 505f0083..b8896209 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -1,7 +1,11 @@ -using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Net.Http.Json; @@ -184,6 +188,144 @@ await response.Content result.Errors.Should().NotBeEmpty(); } + [Theory] + [InlineData("update", false)] + [InlineData("set-primary", false)] + [InlineData("unset-primary", false)] + [InlineData("verify", false)] + [InlineData("delete", false)] + [InlineData("update", true)] + [InlineData("set-primary", true)] + [InlineData("unset-primary", true)] + [InlineData("verify", true)] + [InlineData("delete", true)] + public async Task IdentifierMutation_WhenIdBelongsToAnotherUser_ShouldReturnNotFoundAndPreserveRecords( + string operation, bool admin) + { + var actor = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + if (admin) + await _factory.GrantPermissionsAsync(actor.UserKey, new[] { "*" }); + + using var scope = _factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService() + .Create(TenantKeys.Single); + var normalizer = services.GetRequiredService(); + var value = $"ownership-{Guid.NewGuid():N}@example.com"; + var identifier = UserIdentifier.Create( + Guid.NewGuid(), TenantKeys.Single, victim.UserKey, + UserIdentifierType.Email, value, + normalizer.Normalize(UserIdentifierType.Email, value).Normalized, + _factory.Clock.UtcNow, + isPrimary: operation == "unset-primary", + verifiedAt: operation == "verify" ? null : _factory.Clock.UtcNow); + await store.AddAsync(identifier); + + var before = (await store.GetByUserAsync(victim.UserKey)) + .Select(x => x.Snapshot()).ToArray(); + using var client = CreateClient(); + await AuthenticateIdentifierClientAsync(client, actor.Identifier, actor.Secret); + + // Prove authentication works before exercising the ownership boundary. + using var authenticated = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + authenticated.StatusCode.Should().Be(HttpStatusCode.OK); + + var path = admin + ? $"/auth/admin/users/{actor.UserKey.Value}/identifiers/{operation}" + : $"/auth/me/identifiers/{operation}"; + + // Admin policies forbid modifying the actor themselves. + // Use a separate route target whose identifiers are NOT the victim's. + if (admin) + { + var target = await _factory.CreateLoginUserAsync(); + path = $"/auth/admin/users/{target.UserKey.Value}/identifiers/{operation}"; + } + + using var response = await client.PostAsJsonAsync(path, new + { + id = identifier.Id, + newValue = $"changed-{Guid.NewGuid():N}@example.com", + mode = 0 // DeleteMode.Soft + }); + + var after = await store.GetByUserAsync(victim.UserKey); + using var assertions = new FluentAssertions.Execution.AssertionScope(); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + after.Should().BeEquivalentTo(before, + "rejected operations must not mutate the victim or their primary identifiers"); + } + + [Theory] + [InlineData("update")] + [InlineData("set-primary")] + [InlineData("unset-primary")] + [InlineData("verify")] + [InlineData("delete")] + public async Task IdentifierMutation_WhenIdBelongsToSelf_ShouldSucceed(string operation) + { + using var configuredFactory = operation == "verify" + ? AuthServerFactory.CreateWithServices(services => + services.AddSingleton()) + : null; + + var factory = configuredFactory ?? _factory; + + var owner = await factory.CreateLoginUserAsync(); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var store = services.GetRequiredService().Create(TenantKeys.Single); + var normalizer = services.GetRequiredService(); + var value = $"owned-{Guid.NewGuid():N}@example.com"; + var identifier = UserIdentifier.Create( + Guid.NewGuid(), TenantKeys.Single, owner.UserKey, + UserIdentifierType.Email, value, + normalizer.Normalize(UserIdentifierType.Email, value).Normalized, + factory.Clock.UtcNow, + isPrimary: operation == "unset-primary", + verifiedAt: operation == "verify" ? null : factory.Clock.UtcNow); + await store.AddAsync(identifier); + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", $"identifier-owner-{Guid.NewGuid():N}"); + await AuthenticateIdentifierClientAsync(client, owner.Identifier, owner.Secret); + var newValue = $"updated-{Guid.NewGuid():N}@example.com"; + using var response = await client.PostAsJsonAsync( + $"/auth/me/identifiers/{operation}", + new + { + id = identifier.Id, + newValue, + mode = 0, + proof = operation == "verify" + ? TestUserIdentifierVerifier.ValidProof + : null + }); + response.IsSuccessStatusCode.Should().BeTrue( + $"own identifier operation should succeed: {await response.Content.ReadAsStringAsync()}"); + var saved = await store.GetByIdAsync(identifier.Id); + saved.Should().NotBeNull(); + switch (operation) + { + case "update": saved!.Value.Should().Be(newValue); break; + case "set-primary": saved!.IsPrimary.Should().BeTrue(); break; + case "unset-primary": saved!.IsPrimary.Should().BeFalse(); break; + case "verify": saved!.IsVerified.Should().BeTrue(); break; + case "delete": saved!.IsDeleted.Should().BeTrue(); break; + } + } + // TODO: // Expand UserIdentifier integration coverage: // @@ -215,4 +357,26 @@ private HttpClient CreateClient() return client; } -} \ No newline at end of file + + private static async Task AuthenticateIdentifierClientAsync(HttpClient client, string identifier, string secret) + { + using var login = await client.PostAsJsonAsync("/auth/login", new { identifier, secret }); + login.StatusCode.Should().Be(HttpStatusCode.Found); + login.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + + // Send only cookie name/value pairs, not Set-Cookie attributes. + client.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); + } + + private sealed class TestUserIdentifierVerifier : IUserIdentifierVerifier + { + public const string ValidProof = "integration-valid-proof"; + + public Task VerifyAsync(UserIdentifierVerificationContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(context.Proof == ValidProof); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index 05b18a09..872c3a57 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -210,55 +210,30 @@ await store.GetByUserAsync( } [Fact] - public async Task CreateUser_WithVerifiedEmail_ShouldPersistEmailAsVerified() + public async Task CreateUser_WithVerifiedEmail_ShouldNotPersistEmailAsVerified() { _factory.Clock.Reset(); using var client = CreateClient(); - var username = - $"email-user-{Guid.NewGuid():N}"; + var username = $"email-user-{Guid.NewGuid():N}"; - var email = - $"{Guid.NewGuid():N}@example.com"; + var email = $"{Guid.NewGuid():N}@example.com"; - var result = await CreateUserAsync( - client, - username, - email: email, - emailVerified: true); + var result = await CreateUserAsync(client, username, email: email, emailVerified: true); var userKey = GetUserKey(result); - using var scope = - _factory.Services.CreateScope(); - - var factory = - scope.ServiceProvider - .GetRequiredService(); - - var store = - factory.Create(TenantKeys.Single); - - var identifiers = - await store.GetByUserAsync( - userKey); - - var identifier = identifiers - .Single(x => - x.Type == UserIdentifierType.Email); - - identifier.Value.Should() - .Be(email); - - identifier.IsPrimary.Should() - .BeTrue(); - - identifier.IsVerified.Should() - .BeTrue(); - - identifier.VerifiedAt.Should() - .Be(_factory.Clock.UtcNow); + using var scope = _factory.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var identifiers = await store.GetByUserAsync(userKey); + var identifier = identifiers.Single(x => x.Type == UserIdentifierType.Email); + + identifier.Value.Should().Be(email); + identifier.IsPrimary.Should().BeTrue(); + identifier.IsVerified.Should().BeFalse("anonymous registration must ignore client-supplied verification flags"); + identifier.VerifiedAt.Should().BeNull(); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 0c2a902a..57cd6d66 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -840,6 +840,20 @@ public async Task VerifyUserIdentifierAsync_WhenIdentifierExists_MarksVerifiedAn var expectedVersion = identifier.Version; + const string proof = "valid-test-proof"; + + f.IdentifierVerifier + .Setup(x => x.VerifyAsync( + It.Is(c => + c.Tenant == context.ResourceTenant && + c.UserKey == identifier.UserKey && + c.IdentifierId == identifier.Id && + c.Type == identifier.Type && + c.Value == identifier.Value && + c.Proof == proof), + It.IsAny())) + .ReturnsAsync(true); + f.IdentifierStore .Setup(x => x.GetByIdAsync( identifier.Id, @@ -857,7 +871,8 @@ await f.Sut.VerifyUserIdentifierAsync( context, new VerifyUserIdentifierRequest { - Id = identifier.Id + Id = identifier.Id, + Proof = proof }); identifier.IsVerified.Should().BeTrue(); @@ -869,6 +884,11 @@ await f.Sut.VerifyUserIdentifierAsync( expectedVersion, It.IsAny()), Times.Once); + + f.IdentifierVerifier.Verify(x => x.VerifyAsync( + It.IsAny(), + It.IsAny()), + Times.Once); } // ============================================================ @@ -1552,6 +1572,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat var identifierValidator = new Mock(MockBehavior.Strict); var normalizer = new Mock(MockBehavior.Strict); var identifierAvailability = new Mock(MockBehavior.Strict); + var identifierVerifier = new Mock(MockBehavior.Strict); var sessionFactory = new Mock(MockBehavior.Strict); var sessionStore = new Mock(MockBehavior.Strict); var clock = new Mock(MockBehavior.Strict); @@ -1616,6 +1637,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat normalizer.Object, identifierAvailability.Object, sessionFactory.Object, + identifierVerifier.Object, options, clock.Object); @@ -1629,6 +1651,7 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat validator, identifierValidator, normalizer, + identifierVerifier, sessionStore); } @@ -1795,6 +1818,7 @@ private sealed record Fixture( Mock UserCreateValidator, Mock IdentifierValidator, Mock IdentifierNormalizer, + Mock IdentifierVerifier, Mock SessionStore); private static UserIdentifier CreateIdentifier( From b131ff0dd3ed13639fa05c0062bca568270610b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:41:58 +0300 Subject: [PATCH 03/20] Update release date for First Release to 12.10.2026 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac155657..b9d25d1c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | | First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | 07.04.2026 | -| First Release* | 0.1.0 | Documented & Quality Tested | ✅ Completed | 08.10.2026 | +| First Release* | 0.1.0 | Documented & Quality Tested | ✅ Completed | 12.10.2026 | | Product Expansion | 0.2.0 | Full Auth Modes | 🟡 In Progress | Q4 2026 | | Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟡 In Progress | Q4 2026 | | Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | 🔜 Planned | Q1 2027 | From 892ff54cc96ab65ffce62b064030019f3e4cbf30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:27:32 +0300 Subject: [PATCH 04/20] docs: add welcoming contribution guidelines --- CONTRIBUTING.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4b693c62 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to UltimateAuth + +Thank you for your interest in UltimateAuth! 💜 + +UltimateAuth is a community-first, open-source authentication and authorization framework for modern .NET applications. Contributions of every size are welcome: questions, bug reports, ideas, documentation, examples, tests, code, and thoughtful feedback. + +**You do not need to be an expert to contribute.** Our goal is to make collaboration approachable, constructive, and enjoyable. + +## Join the community + +- [GitHub Issues](https://github.com/CodeBeamOrg/UltimateAuth/issues) — report bugs and suggest improvements. +- [GitHub Discussions](https://github.com/CodeBeamOrg/UltimateAuth/discussions) — ask questions and explore ideas, when discussions are enabled. +- [Discord](https://discord.gg/QscA86dXSR) — meet contributors, ask for help, and share feedback. + +If you are unsure where to start, open an issue or say hello on Discord. + +## Reporting bugs and suggesting features + +A good title and a short, clear description are usually enough to get started. Templates are helpful, but not mandatory. + +For bug reports, please share whatever you can: + +- What you expected to happen and what actually happened. +- The UltimateAuth version, .NET version, and relevant client/runtime, if known. +- A small reproduction, code snippet, logs, or steps to reproduce, if available. + +For feature ideas, describe the problem you want to solve and how you imagine the feature helping users. + +**Please do not post passwords, tokens, private keys, personal data, or sensitive logs.** If you believe you found a security vulnerability, use the private process in [SECURITY.md](SECURITY.md) rather than opening a public issue. + +## Pull requests + +We welcome pull requests, including small fixes and improvements. + +1. Fork the repository and create a branch for your work. +2. Make a focused change, with tests when practical. +3. Open a pull request targeting the **`dev`** branch. +4. Use a descriptive title and explain what changed and why. + +There is no strict pull request format. If a change fixes an issue, feel free to link it. Draft pull requests and work-in-progress discussions are welcome. + +For substantial architectural changes or new authentication behavior, opening an issue first is encouraged so we can align on the approach and avoid wasted effort. This is a recommendation, not a barrier to proposing ideas. + +Maintainers may suggest revisions, request tests, or help refine the approach. Reviews are intended to improve the contribution, not discourage contributors. + +## Building and testing + +The repository contains the `UltimateAuth.slnx` solution and unit/integration test projects. + +From the repository root, you can try: + +```bash +dotnet restore UltimateAuth.slnx +dotnet build UltimateAuth.slnx +dotnet test UltimateAuth.slnx +``` + +Some integration tests or samples may require additional configuration or infrastructure. If a command fails or you cannot run a test, simply mention it in your pull request. You do not need a perfect local environment to start contributing. + +## Architecture and security + +UltimateAuth separates authentication, authorization, sessions, credentials, domain responsibilities, and client/runtime adaptation. + +Before making a substantial architectural or security-sensitive change, please read: + +- [Canonical architecture](.ultimateauth/architecture.md) +- [AI-assisted development guardrails](.ultimateauth/ai-guardrails.md), particularly if you use coding agents + +These documents guide architectural decisions; they are not intended to prevent questions, alternative proposals, or constructive discussion. If you believe an architectural rule should change, raise the proposal for maintainer review rather than silently bypassing it. + +Security-sensitive changes may need more thorough review and regression tests than routine changes. + +## Using AI-assisted tools + +AI tools are welcome as development aids. Contributors remain responsible for understanding their changes, reviewing generated code, and accurately describing what was tested. + +Please do not submit unreviewed generated changes or claim that tests were run when they were not. + +## Licensing + +UltimateAuth is licensed under the [Apache License 2.0](LICENSE.txt). By contributing, you agree that your submitted contributions may be incorporated into the project under its applicable contribution and licensing terms. + +If a separate contributor agreement is introduced, its requirements will be documented before enforcement. + +## A welcoming community + +Be respectful, assume good intentions, and help others learn. Different experience levels and perspectives make the project better. + +**Thank you for helping build UltimateAuth.** From 4e13579c223673f78413d48376070bffa5f8e19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:27:34 +0300 Subject: [PATCH 05/20] docs: add security reporting policy --- SECURITY.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..608b2266 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,72 @@ +# Security Policy + +Security is a core concern for UltimateAuth. We appreciate responsible reports from researchers, users, and contributors, and we aim to handle vulnerabilities carefully and transparently. + +## Supported versions + +UltimateAuth is in active pre-1.0 development. Security fixes are generally prioritized for the latest maintained release line. + +| Version | Security support | +| --- | --- | +| Latest maintained 0.x release | Supported for security review and fixes | +| Older previews and superseded 0.x releases | Not guaranteed | + +This policy does not promise long-term support for every preview or pre-1.0 version. Please update to the latest available maintained version when possible. + +## Reporting a vulnerability + +**Please do not disclose suspected vulnerabilities in public GitHub issues, pull requests, discussions, or public Discord channels.** + +### Preferred: GitHub private vulnerability reporting + +If private vulnerability reporting is enabled for this repository, use GitHub's **Report a vulnerability** option under the repository's **Security** tab: + +[Privately report a vulnerability](https://github.com/CodeBeamOrg/UltimateAuth/security/advisories/new) + +This lets repository maintainers review the report privately before a coordinated disclosure. + +If the reporting option is unavailable, use the alternative contact method below. Do not publish exploit details to work around an unavailable private reporting feature. + +### Alternative: contact maintainers through Discord + +Join the [UltimateAuth Discord community](https://discord.gg/QscA86dXSR) and contact a project maintainer or authorized moderator **by direct message** to request a private reporting channel. + +Do not share exploit steps, secrets, proof-of-concept code, or sensitive technical details in a public Discord channel. Discord direct messages are an initial contact option, not a guarantee of encrypted or confidential vulnerability handling. Maintainers may direct you to GitHub's private reporting workflow for the full report. + +## What to include + +Please share as much of the following as you reasonably can: + +- A clear summary of the suspected vulnerability and its potential impact. +- Affected UltimateAuth package(s), version(s), and authentication mode(s), if known. +- Steps to reproduce or a minimal proof of concept. +- Any relevant configuration or runtime details. +- Whether the issue is already public or has been exploited, if known. + +You do not need a complete exploit or a perfect report to contact us. Please redact credentials, tokens, private keys, and personal information. + +## What happens next + +Maintainers will aim to: + +1. Acknowledge and assess the report as capacity allows. +2. Work with the reporter to clarify reproduction steps and impact. +3. Prepare and validate a fix when the issue is confirmed. +4. Coordinate a security advisory and disclosure when appropriate. +5. Credit the reporter if they wish to be acknowledged. + +We do not promise fixed response or remediation deadlines at this stage. Response time depends on severity, reproducibility, and maintainer availability. + +## Responsible research + +We welcome good-faith research performed on systems and accounts you own or are explicitly authorized to test. + +Please avoid accessing others' data, disrupting services, degrading availability, or disclosing a vulnerability before a reasonable opportunity for coordinated remediation. + +## Scope + +This policy covers security issues in UltimateAuth's maintained source code and official packages. Issues in third-party dependencies, hosting environments, or applications integrating UltimateAuth may require coordination with their respective maintainers. + +For ordinary bugs, feature requests, and non-sensitive questions, please use [GitHub Issues](https://github.com/CodeBeamOrg/UltimateAuth/issues) or [Discord](https://discord.gg/QscA86dXSR). + +Thank you for helping keep the UltimateAuth ecosystem safer. From 8634380f0d4453ffb22ab621ebbc713078e65a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:38:18 +0300 Subject: [PATCH 06/20] docs: add community code of conduct --- CODE_OF_CONDUCT.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..dab0f3a5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,94 @@ +# Code of Conduct + +## Our Commitment + +UltimateAuth is a community-first, open-source project built on collaboration, curiosity, and mutual respect. + +We believe great software is created when people with different backgrounds, experiences, and perspectives feel welcome to contribute. + +Our goal is to maintain a friendly, inclusive, and professional community where everyone can share ideas, ask questions, learn, and contribute with confidence. + +**Every contribution matters, and every contributor deserves respect.** + +## Expected Behavior + +We encourage all community members to: + +- Treat others with kindness, patience, and respect. +- Welcome newcomers and help them get involved. +- Be open to different ideas, experiences, and technical approaches. +- Provide constructive feedback focused on ideas and code rather than individuals. +- Assume good intentions while remaining open to clarification. +- Respect others' time, effort, and contributions. +- Accept feedback gracefully and acknowledge mistakes when they happen. +- Help maintain a productive and collaborative environment. + +Technical disagreement is natural and encouraged. Challenging an idea, questioning an implementation, or proposing an alternative is not a violation of this Code of Conduct. + +**We value respectful disagreement over artificial consensus.** + +## Unacceptable Behavior + +The following behaviors are not acceptable: + +- Harassment, intimidation, threats, or personal attacks. +- Discriminatory or hateful language directed at individuals or groups. +- Repeatedly targeting, insulting, or humiliating other contributors. +- Publishing someone's private information without their permission. +- Deliberate disruption of discussions, reviews, or community activities. +- Sexual harassment or other inappropriate unwanted attention. +- Retaliation against anyone who reports a concern in good faith. + +Disagreement, criticism, and technical debate are welcome when expressed respectfully. + +## Scope + +This Code of Conduct applies to official UltimateAuth community spaces, including: + +- GitHub repositories, issues, pull requests, and discussions. +- Official Discord channels. +- Project documentation and community collaboration spaces. +- Events or communications where individuals officially represent UltimateAuth. + +It also applies to behavior outside these spaces when that behavior directly and substantially affects the safety or participation of the UltimateAuth community. + +## Reporting Concerns + +If you experience or witness behavior that violates this Code of Conduct, please contact a project maintainer privately. + +You may reach maintainers through the [UltimateAuth Discord community](https://discord.gg/QscA86dXSR) by direct message. + +Please avoid sharing sensitive personal information or private reports in public channels. + +Reports will be reviewed with care, fairness, and appropriate discretion. Maintainers may request additional information before taking action. + +Reports concerning a maintainer should be directed to another available maintainer when possible. + +Security vulnerabilities should be reported separately according to [SECURITY.md](SECURITY.md). + +## Enforcement + +Maintainers are responsible for helping maintain a respectful and productive community. + +Depending on the circumstances, appropriate responses may include: + +- A private reminder or clarification. +- A request to modify or remove inappropriate content. +- A warning or temporary restriction. +- Removal from community spaces in serious or repeated cases. + +Responses should be proportionate to the behavior, its impact, and the surrounding circumstances. + +Maintainers should aim for consistent and fair decisions rather than automatic punishment. + +## Our Community Philosophy + +UltimateAuth exists because we believe open-source software becomes better through shared knowledge and collaboration. + +We want contributors to feel comfortable asking questions, challenging assumptions, proposing improvements, and learning from each other. + +**Be respectful. Stay curious. Build together.** + +--- + +This Code of Conduct is maintained by the UltimateAuth project maintainers and may evolve as the community grows. From 8d978ffa9f0e0e2866a5b545612acdd0e6465f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:38:21 +0300 Subject: [PATCH 07/20] docs: add lightweight maintainer-led governance --- GOVERNANCE.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 GOVERNANCE.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..3ee32767 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,192 @@ +# UltimateAuth Governance + +## 1. Project Philosophy + +UltimateAuth is an open-source, community-first authentication and authorization framework for modern .NET applications. + +The project was initiated by CodeBeam and is developed with the goal of creating a secure, unified, extensible, and developer-friendly authentication ecosystem. + +We believe that sustainable open-source software requires both community participation and architectural consistency. + +Our governance philosophy is simple: + +**Community-driven development. Maintainer-led decisions. Shared progress.** + +## 2. Governance Model + +UltimateAuth follows a maintainer-led governance model. + +Project maintainers are responsible for: + +- Protecting the project's security and architectural integrity. +- Reviewing and merging contributions. +- Managing releases and compatibility expectations. +- Maintaining the project roadmap. +- Coordinating security vulnerability handling. +- Supporting healthy community collaboration. + +Maintainers make final decisions on technical direction and project scope. + +However, these decisions should be informed by community feedback, technical evidence, and open discussion whenever practical. + +This governance model may evolve as the contributor community grows. + +## 3. Community Participation + +Everyone is welcome to participate in UltimateAuth. + +Community members may: + +- Report bugs and unexpected behavior. +- Propose features or improvements. +- Submit pull requests. +- Contribute documentation, examples, and tests. +- Participate in technical discussions. +- Suggest architectural improvements. +- Help other developers. + +Participation does not require formal membership or prior approval. + +We value the quality of ideas and contributions, not organizational affiliation or contributor status. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidance. + +## 4. Decision-Making + +### 4.1 Routine Changes + +Routine changes, including documentation improvements, minor bug fixes, tests, and non-breaking implementation improvements, may be reviewed and merged by authorized maintainers. + +Formal voting or prior architectural approval is not required when established project contracts and security guarantees remain unchanged. + +### 4.2 Significant Changes + +Significant changes should be discussed before implementation when practical. + +Examples include: + +- Changes to public APIs. +- Authentication or authorization behavior changes. +- New authentication modes or security mechanisms. +- Major architectural changes. +- Breaking changes. +- New extension mechanisms affecting security boundaries. + +These proposals may be discussed through GitHub issues, discussions, or pull requests. + +Maintainers are responsible for evaluating technical implications, compatibility, security, and alignment with the project's direction. + +### 4.3 Final Decisions + +Maintainers have final decision-making authority over changes accepted into the official UltimateAuth repository. + +Decisions should prioritize: + +1. Security and correctness. +2. Architectural consistency. +3. Developer experience. +4. Maintainability and long-term sustainability. +5. Community needs and feedback. + +When a proposal is declined, maintainers should provide a brief explanation whenever practical. + +Consensus is preferred, but unanimous agreement is not required. + +## 5. Architecture and Security + +UltimateAuth maintains a canonical architectural specification: + +[.ultimateauth/architecture.md](./.ultimateauth/architecture.md) + +Architectural and security-sensitive changes must preserve the project's established invariants or be accompanied by an explicitly reviewed architectural amendment. + +Changes to the canonical architecture require maintainer approval. + +AI-assisted development is additionally governed by: + +[.ultimateauth/ai-guardrails.md](./.ultimateauth/ai-guardrails.md) + +These documents guide implementation and review. They do not prevent contributors from questioning existing decisions or proposing architectural improvements. + +Security vulnerabilities must be reported according to [SECURITY.md](SECURITY.md). + +## 6. Maintainers and Contributors + +### 6.1 Contributors + +A contributor is anyone who helps improve UltimateAuth through code, documentation, testing, feedback, discussions, or community support. + +Contributors do not need special status to participate. + +### 6.2 Maintainers + +Maintainers are trusted contributors responsible for the project's technical and community stewardship. + +Maintainer responsibilities may include: + +- Reviewing contributions. +- Managing issues and pull requests. +- Making architectural decisions. +- Managing releases. +- Coordinating security responses. +- Supporting contributors. + +Maintainer permissions are granted by the existing project maintainers based on demonstrated trust, sustained contributions, technical judgment, and alignment with the project's values. + +Maintainer status is not automatically granted based on contribution count. + +### 6.3 Maintainer Changes + +New maintainers may be invited as the project grows. + +Maintainers who become inactive may voluntarily step back or have their responsibilities adjusted. + +Maintainer permissions may be revoked when necessary to protect project security, integrity, or community trust. + +Such decisions should be handled responsibly and with appropriate discretion. + +## 7. Releases and Roadmap + +Maintainers coordinate official releases and determine release readiness. + +The project roadmap reflects current intentions rather than binding delivery commitments. + +Community feedback may influence priorities, but inclusion in the roadmap does not guarantee implementation or a specific release date. + +Breaking changes, security considerations, compatibility, and maintenance capacity should be considered before releases. + +## 8. Transparency and Communication + +UltimateAuth aims to keep development decisions transparent whenever practical. + +Public GitHub discussions, issues, and pull requests are preferred for ordinary technical collaboration. + +The [UltimateAuth Discord community](https://discord.gg/QscA86dXSR) provides an additional space for questions, feedback, and collaboration. + +Security reports, sensitive moderation matters, and confidential information may require private communication. + +Maintainers are encouraged to explain significant decisions and maintain clear project direction. + +## 9. Future Evolution + +This governance model is intentionally lightweight. + +As UltimateAuth grows, the project may introduce: + +- Additional maintainers. +- Defined maintainer responsibilities. +- Working groups for specific technical areas. +- More structured architectural proposal processes. +- Broader community participation in project governance. + +Such changes should serve the project's needs rather than introduce unnecessary bureaucracy. + +Governance amendments require maintainer approval and should be communicated transparently. + +## 10. Our Commitment + +UltimateAuth is built for developers and strengthened by its community. + +We aim to create an environment where contributions are welcomed, technical decisions are thoughtful, and security is never compromised for convenience. + +**Open to everyone. Guided by principles. Built together.** From 1db7acf7e865bff4862068c7ff7ee74d72b2ae10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:11:20 +0300 Subject: [PATCH 08/20] Final Arrangements for First Stable Release (#66) * fix(credentials): prevent cross-user credential reset * fix(credentials): prevent user enumeration in reset flows * refactor(contracts): remove unused request properties * fix(auth): enforce supported credential types in authentication flows * fix(contracts): align required fields and runtime validation * fix(users): populate missing public contract fields * test(authorization): cover role management and target consistency * Transport PreviewReceipt on Login an TryLogin Processes * Add Verified Username Initialization Guard on User Creation * fix(core): enforce result invariants and standardize IsSuccess * fix(users): enforce pagination limits and ensure consistent user queries * refactor(auth): defer unimplemented authentication flows * refactor(tokens): separate refresh token issuance from transport contracts * test(core): cover JSON converters and typed identifier serialization * fix(auth): preserve multi-valued claims across token issuance and refresh * fix(client): ensure response validation before state events * fix(core): enforce immutability in claims and auth snapshots * fix(core): enforce value object invariants and serialization consistency * fix(client): improve HTTP result mapping and problem details handling * refactor(api): remove dormant contracts and clarify user integration architecture * fix(server): correct ITokenIssuer abstraction namespace * Update Architecture and AI-Guardrails * Update Readme & Codecov * Server Tests * Fix Warnings * Fix Test --- .github/codecov.yml | 4 + .ultimateauth/ai-guardrails.md | 243 ++++- .ultimateauth/architecture.md | 283 +++--- README.md | 27 +- .../Components/Pages/Home.razor.cs | 2 +- ... 20261009201922_001_InitUAuth.Designer.cs} | 10 +- ...ial.cs => 20261009201922_001_InitUAuth.cs} | 16 +- .../Migrations/UAuthDbContextModelSnapshot.cs | 6 +- .../Components/Pages/Home.razor.cs | 2 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Pages/Login.razor.cs | 2 +- .../Components/Pages/ResetCredential.razor.cs | 8 +- ... 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 .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Components/Pages/Login.razor.cs | 2 +- .../Components/Pages/ResetCredential.razor.cs | 8 +- .../Program.cs | 1 + .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Pages/Login.razor.cs | 2 +- .../Pages/ResetCredential.razor.cs | 8 +- .../Components/Dialogs/ProfileDialog.razor.cs | 4 +- .../Pages/Login.razor.cs | 2 +- .../Pages/ResetCredential.razor.cs | 10 +- .../Abstractions/IUAuthPaginationPolicy.cs | 7 + .../Contracts/Auth/AuthIdentitySnapshot.cs | 2 +- .../Contracts/Authority/AccessContext.cs | 4 + .../Contracts/Authority/AccessDecision.cs | 2 +- .../Contracts/Authority/AuthContext.cs | 2 +- .../Contracts/Common/IUAuthTryResult.cs | 2 +- .../Contracts/Common/PageRequest.cs | 27 +- .../Contracts/Common/PagedResult.cs | 2 +- .../Contracts/Login/ExternalLoginRequest.cs | 16 +- .../Contracts/Login/LoginRequest.cs | 2 +- .../Contracts/Login/LoginStatus.cs | 7 +- .../Contracts/Login/ReauthRequest.cs | 14 +- .../Contracts/Login/ReauthResult.cs | 10 +- .../Contracts/Login/TryLoginResult.cs | 2 +- .../Contracts/Logout/LogoutAllRequest.cs | 6 - .../Contracts/Logout/LogoutResponse.cs | 2 +- .../Contracts/Mfa/BeginMfaRequest.cs | 10 +- .../Contracts/Mfa/CompleteMfaRequest.cs | 12 +- .../Contracts/Mfa/MfaChallengeResult.cs | 12 +- .../Contracts/Pkce/PkceCompleteResult.cs | 2 +- .../Contracts/Pkce/TryPkceLoginResult.cs | 2 +- .../Contracts/Refresh/RefreshFlowResult.cs | 15 +- .../Contracts/Session/AuthValidationResult.cs | 6 +- .../Session/SessionValidationResult.cs | 4 +- .../Contracts/Token/RefreshTokenInfo.cs | 16 +- .../Token/RefreshTokenIssuanceResult.cs | 30 + .../Token/RefreshTokenRotationResult.cs | 2 - .../Contracts/Token/TokenIssuanceContext.cs | 5 +- .../User/UserAuthenticationResult.cs | 6 +- .../Domain/Security/PasswordHash.cs | 4 + .../Domain/Session/AuthSessionId.cs | 14 +- .../Domain/Session/ClaimsSnapshot.cs | 27 +- .../Domain/Session/RefreshOutcome.cs | 9 +- .../Domain/User/IAuthSubject.cs | 20 - .../Domain/User/UserKey.cs | 8 +- .../Domain/User/UserRuntimeRecord.cs | 18 + .../Converters/DeviceIdJsonConverter.cs | 3 + .../Converters/SessionRootIdJsonConverter.cs | 4 +- .../Converters/TenantKeyJsonConverter.cs | 9 +- .../Converters/UserKeyJsonConverter.cs | 7 +- .../Infrastructure/SessionValidationMapper.cs | 81 +- .../MultiTenancy/TenantKey.cs | 21 +- .../Options/UAuthOptions.cs | 5 + .../Options/UAuthPaginationOptions.cs | 19 + .../Abstractions/ITokenIssuer.cs | 4 +- .../Auth/Context/AuthFlowContext.cs | 6 +- .../Auth/Context/AuthFlowContextFactory.cs | 5 +- .../Endpoints/LoginEndpointHandler.cs | 12 +- .../Endpoints/LogoutEndpointHandler.cs | 2 +- .../Endpoints/PkceEndpointHandler.cs | 10 +- .../Endpoints/RefreshEndpointHandler.cs | 2 +- .../Endpoints/ValidateEndpointHandler.cs | 24 +- .../Extensions/ServiceCollectionExtensions.cs | 14 +- .../UAuthExceptionHandlingExtensions.cs | 8 +- .../Flows/Login/LoginOrchestrator.cs | 23 +- .../Flows/Pkce/PkceValidationResult.cs | 4 +- .../Credentials/CredentialResponseWriter.cs | 2 +- .../Generators/JwtTokenGenerator.cs | 29 +- .../Issuers/UAuthTokenIssuer.cs | 44 +- .../Orchestrator/UAuthAccessAuthority.cs | 2 +- .../Orchestrator/UAuthAccessOrchestrator.cs | 12 +- .../Infrastructure/RefreshTokenMapper.cs | 14 + .../Options/UAuthServerOptions.cs | 8 +- .../UAuthServerPaginationOptionsValidator.cs | 38 + .../Abstractions/IUAuthFlowService.cs | 10 +- .../Services/PkceService.cs | 6 +- .../Services/RefreshTokenRotationService.cs | 23 +- .../Services/SessionApplicationService.cs | 6 +- .../Services/UAuthFlowService.cs | 38 +- .../Domain/Permission.cs | 24 +- .../Requests/AuthorizationCheckRequest.cs | 2 +- .../Requests/AuthorizationRequest.cs | 24 - .../Stores/EfCoreRoleStore.cs | 7 +- .../Stores/EfCoreRoleStoreFactory.cs | 9 +- .../Stores/InMemoryRoleStore.cs | 7 +- .../Stores/InMemoryRoleStoreFactory.cs | 7 +- .../Endpoints/AuthorizationEndpointHandler.cs | 43 +- .../Services/UserRoleService.cs | 25 +- .../Infrastructure/UAuthRequestClient.cs | 3 - .../TScripts/uauth.js | 52 +- .../wwwroot/uauth.min.js | 2 +- .../Extensions/LoginRequestFormExtensions.cs | 9 +- .../Infrastructure/UAuthResultMapper.cs | 89 +- .../Services/Abstractions/IUserClient.cs | 30 +- .../Services/UAuthAuthorizationClient.cs | 4 +- .../Services/UAuthCredentialClient.cs | 36 +- .../Services/UAuthFlowClient.cs | 208 +++- .../Services/UAuthUserClient.cs | 75 +- .../Services/UAuthUserIdentifierClient.cs | 40 +- .../Dtos/CredentialInfo.cs | 2 +- .../Request/AddCredentialRequest.cs | 10 +- .../Request/BeginResetCredentialRequest.cs | 2 - .../Request/ChangeCredentialRequest.cs | 5 +- .../Request/CompleteResetCredentialRequest.cs | 6 +- .../Request/ResetPasswordRequest.cs | 12 - .../Request/RevokeCredentialRequest.cs | 15 +- .../Responses/AddCredentialResult.cs | 6 +- .../Responses/CredentialActionResult.cs | 6 +- .../Services/CredentialManagementService.cs | 69 +- .../ICredentialAuthenticationService.cs | 22 +- .../Abstractions/IUserSummaryQueryStore.cs | 8 + .../IUserSummaryQueryStoreFactory.cs | 8 + .../Dtos/MfaMethod.cs | 16 +- .../Dtos/UserMfaStatusInfo.cs | 14 +- .../Dtos/UserQuery.cs | 4 + .../Mappers/UserStatusMapper.cs | 11 +- .../Requests/ChangeUserIdentifierRequest.cs | 8 - .../Requests/RegisterUserRequest.cs | 31 - .../Responses/BeginMfaSetupResult.cs | 14 +- .../Responses/GetUserIdentifiersResult.cs | 6 - .../Responses/IdentifierChangeResult.cs | 11 - .../Responses/IdentifierDeleteResult.cs | 10 - .../Responses/IdentifierVerificationResult.cs | 11 - .../Responses/UserCreateResult.cs | 9 +- .../Responses/UserDeleteResult.cs | 10 +- .../Responses/UserStatusChangeResult.cs | 10 +- .../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 + .../Domain/UserProfile.cs | 24 +- .../Endpoints/UserEndpointHandler.cs | 4 +- .../Services/UserApplicationService.cs | 125 +-- .../Abstractions/IUser.cs | 7 - .../Abstractions/IUserSecurityEvents.cs | 16 +- .../Infrastructure/EfCoreTestRuntime.cs | 3 +- .../UserCreationAtomicityTests.cs | 4 +- .../UserQueryPaginationTests.cs | 241 +++++ .../AuthorizationManagementTests.cs | 914 ++++++++++++++++++ .../CredentialManagementTests.cs | 543 +++++++++++ .../LoginTests.cs | 68 +- .../UserLifecycleTests.cs | 128 ++- .../AuthorizationEndpointHandlerTests.cs | 44 +- .../CompiledPermissionSetTests.cs | 28 +- .../Store/EfCoreRoleStoreContractTests.cs | 22 +- .../Store/InMemoryRoleStoreContractTests.cs | 17 +- .../Authorization/UserRoleServiceTests.cs | 109 ++- .../Bunit/UAuthLoginFormTests.cs | 6 +- .../Blazor/UAuthLoginFormSubmissionTests.cs | 14 +- .../Client/RefreshOutcomeParserTests.cs | 18 + .../Client/UAuthClientAuthorizationTests.cs | 21 +- .../Client/UAuthClientCredentialsTests.cs | 6 +- .../UAuthClientEventConsistencyTests.cs | 204 ++++ .../Client/UAuthClientSessionTests.cs | 81 +- .../Client/UAuthClientUserTests.cs | 136 ++- .../Client/UAuthFlowClientTests.cs | 22 +- .../Client/UAuthResultMapperTests.cs | 221 ++++- .../Core/ClaimsSnapshotTests.cs | 29 + .../Core/CoreProductInfoTests.cs | 190 ++++ .../Core/PaginationTests.cs | 142 +++ .../CredentialEndpointHandlerTests.cs | 10 +- .../CredentialManagementServiceTests.cs | 247 ++++- .../Credentials/ResetPasswordTests.cs | 120 ++- .../EfCoreRoleStoreTests.cs | 34 +- .../EfCoreUserIdentifierStoreTests.cs | 21 +- .../EfCoreUserLifecycleStoreTests.cs | 27 +- .../EfCoreUserProfileStoreTests.cs | 31 +- .../Helpers/UAuthClientTestBase.cs | 8 +- .../RemoteSessionValidatorTests.cs | 16 +- .../Server/AccessPipelineTests.cs | 709 ++++++++++++++ .../Server/CookieInfrastructureTests.cs | 649 +++++++++++++ .../Server/CredentialInfrastructureTests.cs | 146 +++ .../Server/CredentialResponseWriterTests.cs | 385 ++++++++ .../EffectiveServerOptionsProviderTests.cs | 317 +++++- .../Server/IdentifierNormalizerTests.cs | 366 +++++++ .../Server/JwtTokenGeneratorTests.cs | 228 +++++ .../Server/LoginEndpointHandlerTests.cs | 12 +- .../Server/LoginOrchestratorTests.cs | 56 ++ .../Server/LogoutEndpointHandlerTests.cs | 4 +- .../Server/PkceEndpointHandlerTests.cs | 20 +- .../Server/PkceServiceTests.cs | 6 +- .../Server/PkceTests.cs | 4 +- .../Server/RefreshFlowServiceTests.cs | 14 +- .../RefreshTokenRotationServiceTests.cs | 82 +- .../Server/ServerOptionsValidatorTests.cs | 570 ++++++++++- .../Server/SessionApplicationServiceTests.cs | 2 + .../Server/SessionTouchServiceTests.cs | 2 +- .../Server/UAuthServerProductInfoTests.cs | 135 +++ .../Server/ValidateCredentialResolverTests.cs | 316 ++++++ .../Server/ValidateEndpointHandlerTests.cs | 8 +- .../Tokens/UAuthTokenIssuerTests.cs | 294 +++++- .../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 | 234 ++++- .../Utility/JsonConverterTests.cs | 416 ++++++++ 233 files changed, 11390 insertions(+), 1468 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/Contracts/Token/RefreshTokenIssuanceResult.cs delete mode 100644 src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.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 create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStore.cs create mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Abstractions/IUserSummaryQueryStoreFactory.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 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 delete mode 100644 src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/UserQueryPaginationTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ClaimsSnapshotTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/CoreProductInfoTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Core/PaginationTests.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/JwtTokenGeneratorTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthServerProductInfoTests.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateCredentialResolverTests.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 create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Utility/JsonConverterTests.cs 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/.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. --- diff --git a/README.md b/README.md index b9d25d1c..a9c2352d 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. --- 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.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/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.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.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/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-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-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/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/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/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/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/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/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/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/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/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/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/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/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/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.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/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/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/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/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/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.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/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/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.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/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/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/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/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 99ff48b2..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>()); @@ -99,11 +118,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.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.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/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/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/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/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/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..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) { @@ -38,6 +39,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 +53,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; } @@ -78,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.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/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9b525f5f..106a7ec7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Abstactions; +namespace CodeBeam.UltimateAuth.Server.Abstractions; /// /// Issues access and refresh tokens according to the active auth mode. @@ -10,5 +10,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/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/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 5d6b0885..f4a1cb59 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, @@ -152,7 +152,7 @@ public async Task TryLoginAsync(HttpContext ctx) { return Results.Ok(new TryLoginResult { - Success = false, + IsSuccess = false, Reason = AuthFailureReason.InvalidCredentials }); } @@ -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, @@ -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/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/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index dfa550da..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; @@ -63,7 +62,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s .PostConfigure(options => { // Add any default values or adjustments here if needed - }); + }) + .ValidateOnStart(); services.AddUltimateAuthServerInternal(); @@ -81,7 +81,8 @@ public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollect { configure?.Invoke(options); }) - .BindConfiguration("UltimateAuth:ResourceApi"); + .BindConfiguration("UltimateAuth:ResourceApi") + .ValidateOnStart(); services.AddUltimateAuthResourceInternal(); @@ -133,8 +134,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/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/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 5c215621..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; @@ -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); } @@ -260,13 +263,17 @@ 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); + + 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/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/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/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 c4d139fb..1649494c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -3,7 +3,7 @@ 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; namespace CodeBeam.UltimateAuth.Server.Infrastructure; @@ -40,7 +40,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 => @@ -50,7 +50,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 +84,7 @@ await store.ExecuteAsync(async transactionCt => }, ct); } - return new RefreshTokenInfo + return new RefreshTokenIssuanceResult { Token = raw, TokenHash = hash, @@ -92,7 +92,7 @@ await store.ExecuteAsync(async transactionCt => }; } - private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, AuthSessionId? sessionId) { string token = _opaqueGenerator.Generate(); @@ -101,26 +101,48 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi Token = token, Format = TokenFormat.Opaque, ExpiresAt = expires, - SessionId = sessionId + 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/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/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/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/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/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/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 75f77923..fbdc931b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,9 +1,10 @@ -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; 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; @@ -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. @@ -125,6 +132,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 +144,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/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/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/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/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.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/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/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 08ba606f..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; @@ -39,11 +40,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, @@ -119,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, @@ -138,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, @@ -178,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, @@ -199,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, @@ -219,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, @@ -248,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..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; } @@ -26,6 +28,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 +52,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,9 +74,11 @@ public async Task> GetRolesAsync(AccessContext context { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey, allowSelf: true); + 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); @@ -132,4 +140,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.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 a1eddceb..01565046 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -57,33 +57,59 @@ 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 (!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 (!result) { - result = {}; + if (isClientError && result.isSuccess) { + throw new Error("Successful authentication result returned with HTTP error."); } const normalized = { - success: result.success ?? false, + isSuccess: result.isSuccess, reason: result.reason ?? null, 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) { + if (normalized.isSuccess) { const form = document.createElement("form"); 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; @@ -91,6 +117,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"; @@ -100,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 61a3dfaf..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: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: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/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; + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 2156fafa..da59de93 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -8,39 +8,39 @@ 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); + var problem = CreateProblem(raw); return new UAuthResult { IsSuccess = false, Status = raw.Status, - Problem = problem + Problem = problem, + TraceId = problem?.TraceId }; } - 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,27 +54,84 @@ public static UAuthResult FromJson(UAuthTransportResult raw) } } - public static UAuthResult From(UAuthTransportResult raw) => FromJson(raw); + 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 = false, + Status = raw.Status, + Problem = problem, + TraceId = problem?.TraceId + }; + } + + 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); } + 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 + 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 ed06416b..5583aec9 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. @@ -117,7 +125,7 @@ public interface IUserClient /// /// The default profile cannot be deleted. /// - Task DeleteMyProfileAsync(ProfileKey profileKey); + Task DeleteMyProfileAsync(DeleteProfileRequest request); /// @@ -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. @@ -138,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/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/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 f103469a..eea71c18 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -63,33 +63,40 @@ 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: { await _post.NavigateAsync(commitUrl, payload); - return new TryLoginResult { Success = true }; + return new TryLoginResult { IsSuccess = true }; } case UAuthSubmitMode.TryAndCommit: @@ -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); @@ -258,7 +333,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) @@ -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 14c67252..58220bab 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -30,24 +30,32 @@ 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) + 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) @@ -155,7 +152,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/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/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.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..5e4d36aa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -2,7 +2,10 @@ public sealed record ChangeCredentialRequest { - public Guid Id { get; init; } + /// + /// 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.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.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/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/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 95735d6e..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, @@ -135,10 +136,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"); @@ -219,9 +223,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,17 +289,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); - - if (!state.HasActiveReset(now)) - throw new UAuthConflictException("reset_request_not_active"); + var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); if (state.IsResetExpired(now)) { @@ -303,11 +308,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"); } @@ -316,7 +333,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"); @@ -337,28 +359,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/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/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/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/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.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.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/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 +//} 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 da9f1da4..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 Succeeded { get; init; } - public string? FailureReason { get; init; } - - public static IdentifierChangeResult Success() => new() { Succeeded = true }; - - public static IdentifierChangeResult Failed(string reason) => new() { Succeeded = 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 00145603..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 Succeeded { 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 }; -} 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 b642949d..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 Succeeded { get; init; } - public string? FailureReason { get; init; } - - public static IdentifierVerificationResult Success() => new() { Succeeded = true }; - - public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = 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.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/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/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/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 674ac3b7..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; } @@ -105,7 +108,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)) @@ -120,7 +124,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)) @@ -371,7 +375,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); @@ -888,119 +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) - { - profileMap.TryGetValue(lifecycle.UserKey, out var profile); - - 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); - - summaries.Add(new UserSummary - { - UserKey = lifecycle.UserKey, - DisplayName = profile?.DisplayName, - UserName = username?.Value, - PrimaryEmail = email?.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.UserName?.ToLowerInvariant().Contains(search) ?? false) || - x.UserKey.Value.ToLowerInvariant().Contains(search)) - .ToList(); - } - - // SORT - summaries = query.SortBy switch + var command = new AccessCommand>( + async innerCt => { - nameof(UserSummary.DisplayName) => - query.Descending - ? summaries.OrderByDescending(x => x.DisplayName).ToList() - : summaries.OrderBy(x => x.DisplayName).ToList(), + var normalized = (UserQuery)(query ?? new UserQuery()).Normalize(_options.Pagination); + var store = _queryStoreFactory.Create(context.ResourceTenant); - 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/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); +//} 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/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.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs new file mode 100644 index 00000000..89403219 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -0,0 +1,543 @@ +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); + + // Should be true for enumeration. + replay.IsSuccessStatusCode.Should().BeTrue(); + + 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(); + } + + + [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) + { + 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.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..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; @@ -48,7 +50,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 +390,7 @@ await firstResponse.Content .ReadFromJsonAsync(); firstResult.Should().NotBeNull(); - firstResult!.Succeeded.Should().BeTrue(); + firstResult!.IsSuccess.Should().BeTrue(); var firstUserKey = GetUserKey(firstResult); @@ -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 @@ -569,7 +691,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/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs index 655e92f7..8c5e5782 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,40 +684,36 @@ 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); } + [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 // ========================================================= 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/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 54ee688b..bc11a48b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs @@ -6,10 +6,10 @@ 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; -using Microsoft.AspNetCore.Identity; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -27,8 +27,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 +68,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 +100,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 +135,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 +175,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 +209,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 +237,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 +276,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 +339,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 +386,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 +435,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 +469,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( @@ -490,19 +490,18 @@ 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] 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 // ========================================================= @@ -903,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); @@ -947,12 +998,20 @@ public Fixture() AccessOrchestrator.Object, UserRoleFactory.Object, RoleFactory.Object, + PaginationPolicy, Clock.Object); } 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/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/UAuthClientAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs index 664248a7..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] @@ -80,7 +93,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/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 aa718bf4..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; @@ -38,7 +41,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 +56,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 +90,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 +101,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 +114,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 +131,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 +144,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 +181,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); } @@ -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/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/Client/UAuthResultMapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs index 301f457d..475ab82e 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,197 @@ 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(); + } + + [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 + { + 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/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(); + } +} 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/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/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index a30dcddb..e7c280b1 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); @@ -310,7 +305,7 @@ public async Task CompleteResetAsync_WhenUnauthenticated_IsAllowed() .BeOfType>() .Subject; - ok.Value!.Succeeded.Should().BeTrue(); + ok.Value!.IsSuccess.Should().BeTrue(); } // ========================================================= @@ -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 b6f2bf97..c38605ea 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; @@ -92,11 +91,10 @@ public async Task AddAsync_HashesSecretAndPersistsCredentialForTargetUser() context, new AddCredentialRequest { - Type = CredentialType.Password, Secret = "new-password" }); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); persisted.Should().NotBeNull(); persisted!.UserKey.Should().Be(user); @@ -167,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()), @@ -487,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( @@ -537,7 +534,7 @@ public async Task RevokeAsync_WhenValid_RevokesAndSavesExpectedVersion() Id = id }); - result.Succeeded.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); } // --------------------------------------------------------- @@ -593,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(); @@ -640,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( @@ -652,6 +649,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.IsSuccess.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 +1029,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); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 99785fda..6b2190b9 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; @@ -102,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] @@ -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.IsSuccess.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.IsSuccess.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); @@ -167,14 +178,19 @@ public async Task Reset_token_should_be_single_use() CompleteContext(), CompleteRequest(token, "newpass123")); - first.Succeeded.Should().BeTrue(); + first.IsSuccess.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.IsSuccess.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; + } } 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/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()) 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/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/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/LoginEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs index 9aa2dda2..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); @@ -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() @@ -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( @@ -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); + } } 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..df376009 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); @@ -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)) }; } @@ -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/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index e4793a89..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; @@ -13,6 +12,7 @@ using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using Moq; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit.Server; @@ -26,13 +26,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()); @@ -62,11 +63,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(), @@ -118,11 +121,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), @@ -179,6 +184,7 @@ public async Task RotateAsync_WhenTokenIsAlreadyConsumed_ShouldRevokeCompromised var issuer = new Mock(); + var claimsProvider = new Mock(); validator .Setup(x => x.ValidateAsync( @@ -211,7 +217,8 @@ public async Task RotateAsync_WhenTokenIsAlreadyConsumed_ShouldRevokeCompromised new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( @@ -263,6 +270,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( @@ -270,12 +278,13 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou It.IsAny(), RefreshTokenPersistence.DoNotPersist, It.IsAny())) - .ReturnsAsync((RefreshTokenInfo?)null); + .ReturnsAsync((RefreshTokenIssuanceResult?)null); var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow(tenant, multiTenant: true), @@ -312,11 +321,12 @@ 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(); var accessToken = CreateAccessToken(); - var refreshToken = CreateRefreshTokenInfo(); + var refreshToken = CreateRefreshTokenIssuanceResult(); issuer .Setup(x => x.IssueAccessTokenAsync( @@ -333,22 +343,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, claimsProvider.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); @@ -394,6 +398,7 @@ public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForToken var issuer = new Mock(); + var claimsProvider = new Mock(); TokenIssuanceContext? captured = null; @@ -408,7 +413,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( @@ -432,6 +438,7 @@ public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIs var issuer = new Mock(); + var claimsProvider = new Mock(); TokenIssuanceContext? captured = null; @@ -448,7 +455,8 @@ public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIs CreateStoreFactory( TenantKey.Single, store).Object, - issuer.Object); + issuer.Object, + claimsProvider.Object); var result = await sut.RotateAsync( CreateFlow( @@ -473,11 +481,12 @@ 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(); var accessToken = CreateAccessToken(); - var refreshToken = CreateRefreshTokenInfo(); + var refreshToken = CreateRefreshTokenIssuanceResult(); issuer .Setup(x => x.IssueAccessTokenAsync( @@ -505,7 +514,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), @@ -533,6 +543,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 +646,7 @@ private static AccessToken CreateAccessToken() ExpiresAt = Now.AddMinutes(15) }; - private static RefreshTokenInfo CreateRefreshTokenInfo() + private static RefreshTokenIssuanceResult CreateRefreshTokenIssuanceResult() => new() { Token = "new-refresh-token", @@ -646,7 +672,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) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index fe385ea1..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; @@ -359,4 +360,571 @@ 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); + } + + [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/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/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/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()); + } + } +} 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/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; } 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 57cd6d66..340e9dec 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); @@ -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); 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 1eb9291482fbc81e5b5f3166422fd51d140efd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:21:33 +0300 Subject: [PATCH 09/20] Fix Credential & User Flows (#67) * fix(credentials): enforce configurable session invalidation on credential changes and recovery * fix(credentials): prevent account enumeration in recovery flows * fix(credentials): prevent information disclosure in anonymous password reset * fix(credentials): reject unsupported credential types in reset flows * fix(users): align query sorting and stabilize pagination across providers * fix(server): reject unsupported body delivery during response resolution --- .../Credential/ICredentialSessionPolicy.cs | 8 + .../Options/CredentialChangeReason.cs | 8 + .../Validators/CredentialSessionBehavior.cs | 8 + .../Auth/Response/AuthResponseResolver.cs | 4 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../UAuthCredentialSessionPolicy.cs | 30 + .../UAuthCredentialSessionPolicyOptions.cs | 37 ++ .../Options/UAuthServerOptions.cs | 6 + .../Endpoints/AuthorizationEndpointHandler.cs | 9 +- .../Services/UAuthCredentialClient.cs | 2 +- .../Services/UAuthFlowClient.cs | 2 +- .../Services/UAuthSessionClient.cs | 2 +- .../Services/UAuthUserClient.cs | 3 +- .../Endpoints/CredentialEndpointHandler.cs | 76 ++- .../Services/CredentialManagementService.cs | 109 +++- .../Stores/EfCoreUserSummaryQueryStore.cs | 47 +- .../Stores/InMemoryUserSummaryQueryStore.cs | 56 +- .../Endpoints/UserEndpointHandler.cs | 23 +- .../Services/IUserApplicationService.cs | 4 +- .../Services/UserApplicationService.cs | 43 +- .../CredentialManagementTests.cs | 568 +++++++++++++++++- .../SdkEnpointContractTests.cs | 260 ++++++++ .../Client/UAuthClientCredentialsTests.cs | 42 +- .../CredentialEndpointHandlerTests.cs | 50 +- .../CredentialManagementServiceTests.cs | 23 + .../Credentials/ResetPasswordTests.cs | 41 +- .../Users/UserApplicationServiceTests.cs | 18 + .../Users/UserEndpointHandlerTests.cs | 65 +- 28 files changed, 1382 insertions(+), 163 deletions(-) create mode 100644 src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs create mode 100644 src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs create mode 100644 src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs new file mode 100644 index 00000000..6c48f9ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ICredentialSessionPolicy +{ + CredentialSessionBehavior Resolve(CredentialChangeReason reason); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs b/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs new file mode 100644 index 00000000..da589672 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum CredentialChangeReason +{ + SelfChange = 0, + AdminChange = 10, + Recovery = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs new file mode 100644 index 00000000..6665f0ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum CredentialSessionBehavior +{ + PreserveAll = 0, + PreserveCurrent = 10, + RevokeAll = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 7d5bedd6..41e7e278 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -72,6 +72,10 @@ private static void Validate(UAuthResponseOptions response) private static void ValidateDelivery(CredentialResponseOptions delivery) { + // TODO: + if (delivery.Mode == TokenResponseMode.Body) + throw new NotSupportedException($"Body credential delivery is not supported for '{delivery.Kind}'."); + if (delivery.Mode == TokenResponseMode.Cookie && delivery.Cookie is null) { throw new InvalidOperationException($"Credential '{delivery.Kind}' is configured as Cookie but no cookie options were bound."); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index d3566994..650a6837 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -263,6 +263,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs new file mode 100644 index 00000000..d5dcfbd9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UAuthCredentialSessionPolicy : ICredentialSessionPolicy +{ + private readonly UAuthCredentialSessionPolicyOptions _options; + + public UAuthCredentialSessionPolicy(IOptions options) + { + _options = options.Value.CredentialSessionPolicy; + } + + public CredentialSessionBehavior Resolve(CredentialChangeReason reason) + { + return reason switch + { + CredentialChangeReason.SelfChange => _options.OnSelfChange, + + CredentialChangeReason.AdminChange => _options.OnAdminChange, + + CredentialChangeReason.Recovery => _options.OnRecovery, + + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null) + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs new file mode 100644 index 00000000..b495a0f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCredentialSessionPolicyOptions +{ + /// + /// Session behavior when a user changes their own credential. + /// By default, the current session chain is preserved. + /// + public CredentialSessionBehavior OnSelfChange { get; set; } = CredentialSessionBehavior.PreserveCurrent; + + /// + /// Session behavior when an administrator changes a user's credential. + /// By default, all session chains are revoked. + /// + public CredentialSessionBehavior OnAdminChange { get; set; } = CredentialSessionBehavior.RevokeAll; + + /// + /// Session behavior after successful credential recovery. + /// By default, all session chains are revoked. + ///

+ /// Preserving the current session don't work well with recovery flows, because the current session is usually the one that was lost and is being recovered. + /// In this case, RevokeAll option is using instead of PreserveCurrent automatically. + ///
+ public CredentialSessionBehavior OnRecovery { get; set; } = CredentialSessionBehavior.RevokeAll; + + internal UAuthCredentialSessionPolicyOptions Clone() + { + return new UAuthCredentialSessionPolicyOptions + { + OnSelfChange = OnSelfChange, + OnAdminChange = OnAdminChange, + OnRecovery = OnRecovery + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 7e91f86b..34af5b93 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -81,6 +81,11 @@ public sealed class UAuthServerOptions public UAuthResetOptions ResetCredential { get; init; } = new(); + /// + /// Controls session invalidation after credential changes and recovery. + /// + public UAuthCredentialSessionPolicyOptions CredentialSessionPolicy { get; init; } = new(); + public UAuthHubOptions Hub { get; set; } = new(); /// @@ -151,6 +156,7 @@ internal UAuthServerOptions Clone() PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), + CredentialSessionPolicy = CredentialSessionPolicy.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), Identifiers = Identifiers.Clone(), diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index f84692c5..2c52e1b3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -173,7 +173,14 @@ public async Task CreateRoleAsync(HttpContext ctx) var role = await _roles.CreateAsync(accessContext, req.Name, req.Permissions, ctx.RequestAborted); - return Results.Ok(role); + return Results.Ok(new RoleInfo + { + Id = role.Id, + Name = role.Name, + Permissions = role.Permissions.ToArray(), + CreatedAt = role.CreatedAt, + UpdatedAt = role.UpdatedAt + }); } public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 5a0fbeb2..dd70b892 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -110,7 +110,7 @@ public async Task> CompleteResetUserAsync(Us public async Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/credentials/delete")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"), request); return UAuthResultMapper.From(raw); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index eea71c18..42a889ee 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -441,7 +441,7 @@ public async Task> LogoutMyDeviceAsync(LogoutDeviceReq { var result = UAuthResultMapper.FromJson(raw); - if (result.Value?.CurrentChain == true) + if (result.IsSuccess && result.Value?.CurrentChain == true) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index 80e5ce01..116dea77 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -42,7 +42,7 @@ public async Task> RevokeMyChainAsync(SessionChainId c var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId.Value}/revoke")); var result = UAuthResultMapper.FromJson(raw); - if (result.Value?.CurrentChain == true) + if (result.IsSuccess && result.Value?.CurrentChain == true) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 58220bab..8393787a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -51,8 +51,7 @@ public async Task DeleteMeAsync() if (result.IsSuccess) { - await _events.PublishAsync( - new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); } return result; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index cd214a18..3ef63946 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -70,12 +70,14 @@ public async Task ChangeSecretAsync(HttpContext ctx) return Results.Ok(result); } + public async Task RevokeAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -83,7 +85,14 @@ public async Task RevokeAsync(HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.RevokeAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); + return Results.NoContent(); } @@ -173,13 +182,16 @@ public async Task ChangeSecretAdminAsync(UserKey userKey, HttpContext c return Results.Ok(result); } + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -187,18 +199,27 @@ public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) resource: "credentials", resourceId: userKey.Value); - await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.RevokeAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); return Results.NoContent(); } + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -206,17 +227,25 @@ public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) resource: "credentials", resourceId: userKey.Value); - await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.DeleteAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); return Results.NoContent(); } + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -224,16 +253,19 @@ public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx resource: "credentials", resourceId: userKey.Value); - await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -241,8 +273,9 @@ public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext resource: "credentials", resourceId: userKey.Value); - await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) @@ -257,4 +290,23 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) error = null; return true; } + + + private static IResult MapCredentialActionFailure(CredentialActionResult result) + { + var reason = result.Error ?? "credential_action_failed"; + + return reason switch + { + "credential_not_found" => Results.Problem( + statusCode: StatusCodes.Status404NotFound, + title: "Credential not found", + detail: reason), + + _ => Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Credential action failed", + detail: reason) + }; + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 3a56ef57..65a462aa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.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.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -25,6 +26,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ILoginIdentifierResolver _identifierResolver; private readonly ISessionStoreFactory _sessionFactory; private readonly ICredentialResetNotifier _resetNotifier; + private readonly ICredentialSessionPolicy _credentialSessionPolicy; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -39,6 +41,7 @@ public CredentialManagementService( ILoginIdentifierResolver identifierResolver, ISessionStoreFactory sessionFactory, ICredentialResetNotifier resetNotifier, + ICredentialSessionPolicy credentialSessionPolicy, IOptions options, IClock clock) { @@ -52,6 +55,7 @@ public CredentialManagementService( _identifierResolver = identifierResolver; _sessionFactory = sessionFactory; _resetNotifier = resetNotifier; + _credentialSessionPolicy = credentialSessionPolicy; _options = options.Value; _clock = clock; } @@ -156,15 +160,10 @@ public async Task ChangeSecretAsync(AccessContext contex var updated = pwd.ChangeSecret(newHash, now); await store.SaveAsync(updated, oldVersion, innerCt); - var sessionStore = _sessionFactory.Create(context.ResourceTenant); - if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) - { - await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, innerCt); - } - else - { - await sessionStore.RevokeAllChainsAsync(subjectUser, now, innerCt); - } + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction + ? CredentialChangeReason.SelfChange + : CredentialChangeReason.AdminChange, + context.ActorChainId, now, innerCt); return ChangeCredentialResult.Success(pwd.Type); }); @@ -213,6 +212,13 @@ public async Task BeginResetAsync(AccessContext cont if (string.IsNullOrWhiteSpace(request.Identifier)) throw new UAuthValidationException("identifier_required"); + // TODO: Support other credential types in the future (e.g., Totp, Passkey, etc.) + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("credential_reset_type_not_supported"); + + if (request.ResetCodeType is not (ResetCodeType.Token or ResetCodeType.Code)) + throw new UAuthValidationException("invalid_reset_code_type"); + var now = _clock.UtcNow; // Anonymous callers must not control the proof's lifetime. var validity = _options.ResetCredential.TokenValidity; @@ -231,23 +237,23 @@ public async Task BeginResetAsync(AccessContext cont if (resolution?.UserKey is not UserKey userKey) return result; // Enumeration protection - var state = await _authenticationSecurityManager - .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); - string token; + // An existing active reset must not be observable + // through the anonymous endpoint. + if (state.HasActiveReset(now) && context.IsAnonymousContext) + return result; - if (request.ResetCodeType == ResetCodeType.Token) + var token = request.ResetCodeType switch { - token = _tokenGenerator.Generate(); - } - else if (request.ResetCodeType == ResetCodeType.Code) - { - token = _numericCodeGenerator.Generate(_options.ResetCredential.CodeLength); - } - else - { - throw new UAuthValidationException("invalid_reset_code_type"); - } + ResetCodeType.Token => _tokenGenerator.Generate(), + + ResetCodeType.Code => _numericCodeGenerator.Generate( + _options.ResetCredential.CodeLength), + + _ => throw new UAuthValidationException( + "invalid_reset_code_type") + }; var tokenHash = _tokenHasher.Hash(token); @@ -285,6 +291,10 @@ public async Task CompleteResetAsync(AccessContext conte if (string.IsNullOrWhiteSpace(request.NewSecret)) throw new UAuthValidationException("new_secret_required"); + // TODO: Support other credential types in the future (e.g., Totp, Passkey, etc.) + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("credential_reset_type_not_supported"); + var now = _clock.UtcNow; var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); @@ -305,6 +315,10 @@ public async Task CompleteResetAsync(AccessContext conte var version2 = state.SecurityVersion; var cleared = state.ClearReset(); await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + throw new UAuthConflictException("reset_expired"); } @@ -341,7 +355,22 @@ public async Task CompleteResetAsync(AccessContext conte } if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - throw new UAuthValidationException("credential_secret_same"); + { + if (!context.IsAnonymousContext) + throw new UAuthValidationException("credential_secret_same"); + + // Valid reset token, but password is unchanged. + // Consume the token to preserve single-use semantics. + var version = state.SecurityVersion; + var consumed = state.ConsumeReset(now); + + await _authenticationSecurityManager.UpdateAsync( + consumed, + version, + innerCt); + + return CredentialActionResult.Success(); + } var version3 = state.SecurityVersion; state = state.ConsumeReset(now); @@ -352,6 +381,7 @@ public async Task CompleteResetAsync(AccessContext conte var updated = pwd.ChangeSecret(newHash, now); await store.SaveAsync(updated, oldVersion, innerCt); + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant ,userKey, CredentialChangeReason.Recovery, actorChainId: null, now, innerCt); return CredentialActionResult.Success(); }); @@ -421,4 +451,35 @@ async Task IUserCredentialsInternalService.DeleteInterna await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, ct); return CredentialActionResult.Success(); } + + // ---------------------------------------- + // HELPERS + // ---------------------------------------- + private async Task ApplyCredentialSessionPolicyAsync(TenantKey tenant, UserKey subjectUser, CredentialChangeReason reason, + SessionChainId? actorChainId, DateTimeOffset now, CancellationToken ct) + { + var behavior = _credentialSessionPolicy.Resolve(reason); + + if (behavior == CredentialSessionBehavior.PreserveAll) + return; + + var sessionStore = _sessionFactory.Create(tenant); + + switch (behavior) + { + case CredentialSessionBehavior.PreserveCurrent + when reason == CredentialChangeReason.SelfChange && actorChainId is SessionChainId chainId: + await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, ct); + break; + + case CredentialSessionBehavior.PreserveCurrent: + case CredentialSessionBehavior.RevokeAll: + + await sessionStore.RevokeAllChainsAsync(subjectUser, now, ct); + break; + + default: + throw new InvalidOperationException($"Unsupported credential session behavior: {behavior}"); + } + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs index 453043e0..a33c7790 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs @@ -127,37 +127,48 @@ public async Task> QueryAsync(UserQuery query, Cancella { nameof(UserSummary.DisplayName) => query.Descending - ? summaries.OrderByDescending(x => x.DisplayName).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.DisplayName).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), nameof(UserSummary.UserName) => query.Descending - ? summaries.OrderByDescending(x => x.UserName).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.UserName).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), nameof(UserSummary.PrimaryEmail) => query.Descending - ? summaries.OrderByDescending(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), 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), + ? summaries.OrderByDescending(x => x.PrimaryPhone) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.PrimaryPhone) + .ThenBy(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) + ? 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; + var offset = ((long)normalized.PageNumber - 1) * normalized.PageSize; if (offset > int.MaxValue) { diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs index b737c21c..3fe9285b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs @@ -114,28 +114,44 @@ public async Task> QueryAsync(UserQuery query, Cancella ).ToList(); } - IEnumerable ordered = query.SortBy switch + + IOrderedEnumerable OrderByText(Func selector) + { + return query.Descending + ? summaries.OrderByDescending(selector, StringComparer.OrdinalIgnoreCase) + : summaries.OrderBy(selector, StringComparer.OrdinalIgnoreCase); + } + + IOrderedEnumerable OrderByCreatedAt() + { + return query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt) + : summaries.OrderBy(x => x.CreatedAt); + } + + IOrderedEnumerable 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) + OrderByText(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.UserName) => + OrderByText(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.PrimaryEmail) => + OrderByText(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.PrimaryPhone) => + OrderByText(x => x.PrimaryPhone) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + _ => OrderByCreatedAt() .ThenBy(x => x.UserKey.Value) }; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 65d8da2a..52a9e2a3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -92,8 +92,13 @@ public async Task ChangeStatusSelfAsync(HttpContext ctx) resource: "users", resourceId: flow?.UserKey?.Value); - await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + + return result.IsSuccess + ? Results.Ok(result) + : Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + detail: result.FailureReason); } public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx) @@ -110,8 +115,13 @@ public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext c resource: "users", resourceId: userKey.Value); - await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + + return result.IsSuccess + ? Results.Ok(result) + : Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + detail: result.FailureReason); } public async Task GetMeAsync(HttpContext ctx) @@ -220,8 +230,9 @@ public async Task DeleteAsync(UserKey userKey, HttpContext ctx) ["deleteMode"] = request.Mode }); - await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } public async Task CreateProfileSelfAsync(HttpContext ctx) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 30a6dcb4..d9938222 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -11,7 +11,7 @@ public interface IUserApplicationService Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); - Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default); Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); @@ -37,5 +37,5 @@ public interface IUserApplicationService Task CheckIdentifierAvailabilityAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default); Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); - Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); + Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 902d61d8..b46ba8ea 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -173,41 +173,56 @@ await identifierStore.AddAsync( return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) + + public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var newStatus = request switch { ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), ChangeUserStatusAdminRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), + _ => throw new InvalidOperationException("invalid_request") }; var targetUserKey = context.GetTargetUserKey(); var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var current = await lifecycleStore.GetAsync(userLifecycleKey, innerCt); - var now = _clock.UtcNow; if (current is null) throw new UAuthNotFoundException("user_not_found"); if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) + { throw new UAuthConflictException("self_transition_not_allowed"); + } - if (!context.IsSelfAction) + if (!context.IsSelfAction && newStatus is UserStatus.SelfSuspended) { - if (newStatus is UserStatus.SelfSuspended) - throw new UAuthConflictException("admin_cannot_set_self_status"); + throw new UAuthConflictException("admin_cannot_set_self_status"); } + + var previousStatus = current.Status; + + // NoChange: no persistence operation is required. + if (previousStatus == newStatus) + return UserStatusChangeResult.NoChange(previousStatus); + + var now = _clock.UtcNow; + var newEntity = current.ChangeStatus(now, newStatus); await lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); + + return UserStatusChangeResult.Success(previousStatus, newStatus); }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); + return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => @@ -248,9 +263,10 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -263,14 +279,15 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + await lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); await identifierStore.DeleteByUserAsync(targetUserKey, request.Mode, now, innerCt); - var profiles = await profileStore.GetAllProfilesByUserAsync(targetUserKey, innerCt); foreach (var profile in profiles) { var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, request.Mode, now, innerCt); } @@ -280,12 +297,16 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque } var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(targetUserKey, now, innerCt); + + return UserDeleteResult.Success(request.Mode); }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); + return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #endregion diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs index 89403219..b71421cd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -13,6 +14,7 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Integration; @@ -162,10 +164,21 @@ await factory.GrantPermissionsAsync( $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", target.Identifier); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); notifier.Notifications.Should() .ContainSingle(x => x.UserKey == target.UserKey); + + var body = await response.Content.ReadAsStringAsync(); + + body.Should().NotContain( + notifier.Notifications.Single().Token); } [Fact] @@ -242,7 +255,12 @@ await factory.GrantPermissionsAsync( $"{url}/begin", target.Identifier); - begin.StatusCode.Should().Be(HttpStatusCode.NoContent); + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var beginResult = await begin.Content + .ReadFromJsonAsync(); + + beginResult.Should().NotBeNull(); var token = notifier.Notifications .Single(x => x.UserKey == target.UserKey) @@ -257,7 +275,13 @@ await factory.GrantPermissionsAsync( token, newPassword); - complete.StatusCode.Should().Be(HttpStatusCode.NoContent); + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + var completeResult = await complete.Content + .ReadFromJsonAsync(); + + completeResult.Should().NotBeNull(); + completeResult!.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(factory, target, newPassword); await AssertPasswordRejectedAsync(factory, target, target.Secret); @@ -400,6 +424,532 @@ await AssertPasswordAsync( existingUser.Secret); } + [Fact] + public async Task AnonymousRecovery_DefaultPolicy_RevokesAllExistingChains() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + // Two independent clients with different X-UDID values. + using var firstClient = CreateClient(factory); + using var secondClient = CreateClient(factory); + + await AuthenticateAsync(firstClient, user); + await AuthenticateAsync(secondClient, user); + + // Verify both chains exist and are active before recovery. + using (var scope = factory.Services.CreateScope()) + { + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + var chains = await store.GetChainsByUserAsync(user.UserKey); + + chains.Should().HaveCount(2); + chains.Should().OnlyContain(c => !c.IsRevoked); + } + + // Recovery must not rely on either authenticated client. + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == user.UserKey) + .Token; + + var newPassword = $"Recovered-{Guid.NewGuid():N}!"; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + newPassword); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + // Recovery must actually change the password. + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + // Both previously active chains must now be revoked. + using (var scope = factory.Services.CreateScope()) + { + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + var chains = await store.GetChainsByUserAsync(user.UserKey); + + chains.Should().HaveCount(2); + chains.Should().OnlyContain(c => c.IsRevoked); + } + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false)] + [InlineData(CredentialSessionBehavior.RevokeAll, true)] + public async Task Recovery_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool shouldRevoke) + { + var notifier = new TestResetNotifier(); + + using var factory = AuthServerFactory.Create( + options => + { + options.CredentialSessionPolicy.OnRecovery = behavior; + }, + services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var firstClient = CreateClient(factory); + using var secondClient = CreateClient(factory); + + await AuthenticateAsync(firstClient, user); + await AuthenticateAsync(secondClient, user); + + var before = await GetChainsAsync(factory, user.UserKey); + + before.Should().HaveCount(2); + before.Should().OnlyContain(c => !c.IsRevoked); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == user.UserKey) + .Token; + + var newPassword = $"Recovered-{Guid.NewGuid():N}!"; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + newPassword); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + var after = await GetChainsAsync(factory, user.UserKey); + + after.Should().HaveCount(2); + + foreach (var chain in after) + { + chain.IsRevoked.Should().Be( + shouldRevoke, + $"OnRecovery = {behavior} must be respected"); + } + } + + [Fact] + public async Task Recovery_RevokeAll_ShouldNotAffectOtherUsers() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + using var targetClient = CreateClient(factory); + using var otherClient = CreateClient(factory); + + await AuthenticateAsync(targetClient, target); + await AuthenticateAsync(otherClient, other); + + var targetBefore = await GetChainsAsync(factory, target.UserKey); + var otherBefore = await GetChainsAsync(factory, other.UserKey); + + targetBefore.Should().ContainSingle(c => !c.IsRevoked); + otherBefore.Should().ContainSingle(c => !c.IsRevoked); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + target.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == target.UserKey) + .Token; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + target.Identifier, + token, + $"Recovered-{Guid.NewGuid():N}!"); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + var targetAfter = await GetChainsAsync(factory, target.UserKey); + var otherAfter = await GetChainsAsync(factory, other.UserKey); + + targetAfter.Should().ContainSingle(c => c.IsRevoked); + otherAfter.Should().ContainSingle(c => !c.IsRevoked); + } + + [Fact] + public async Task Recovery_InvalidToken_ShouldNotRevokeExistingChains() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var authenticatedClient = CreateClient(factory); + await AuthenticateAsync(authenticatedClient, user); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + "invalid-token", + $"Changed-{Guid.NewGuid():N}!"); + + // Anonymous endpoint may return a generic success + // to prevent account enumeration. + complete.IsSuccessStatusCode.Should().BeTrue(); + + var chains = await GetChainsAsync(factory, user.UserKey); + + chains.Should().ContainSingle(c => !c.IsRevoked); + + await AssertPasswordAsync(factory, user, user.Secret); + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false, false)] + [InlineData(CredentialSessionBehavior.PreserveCurrent, false, true)] + [InlineData(CredentialSessionBehavior.RevokeAll, true, true)] + public async Task SelfChange_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool revokeCurrent, bool revokeOther) + { + using var factory = AuthServerFactory.Create(options => + { + options.CredentialSessionPolicy.OnSelfChange = behavior; + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var currentClient = CreateClient(factory); + using var otherClient = CreateClient(factory); + + await AuthenticateAsync(currentClient, user); + + var firstChains = await GetChainsAsync(factory, user.UserKey); + var currentChain = firstChains.Should().ContainSingle().Subject; + + await AuthenticateAsync(otherClient, user); + + var before = await GetChainsAsync(factory, user.UserKey); + before.Should().HaveCount(2); + + var otherChain = before.Single( + c => c.ChainId != currentChain.ChainId); + + before.Should().OnlyContain(c => !c.IsRevoked); + + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + + var response = await currentClient.PostAsJsonAsync( + "/auth/me/credentials/change", + new ChangeCredentialRequest + { + CurrentSecret = user.Secret, + NewSecret = newPassword + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + var after = await GetChainsAsync(factory, user.UserKey); + + after.Single(c => c.ChainId == currentChain.ChainId) + .IsRevoked.Should().Be(revokeCurrent); + + after.Single(c => c.ChainId == otherChain.ChainId) + .IsRevoked.Should().Be(revokeOther); + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false)] + [InlineData(CredentialSessionBehavior.RevokeAll, true)] + public async Task AdminChange_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool shouldRevoke) + { + using var factory = AuthServerFactory.Create(options => + { + options.CredentialSessionPolicy.OnAdminChange = behavior; + }); + + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Credentials.ChangeAdmin]); + + using var adminClient = CreateClient(factory); + using var targetClient1 = CreateClient(factory); + using var targetClient2 = CreateClient(factory); + + await AuthenticateAsync(adminClient, admin); + await AuthenticateAsync(targetClient1, target); + await AuthenticateAsync(targetClient2, target); + + var before = await GetChainsAsync(factory, target.UserKey); + before.Should().HaveCount(2); + before.Should().OnlyContain(c => !c.IsRevoked); + + var newPassword = $"AdminChanged-{Guid.NewGuid():N}!"; + + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/change", + new ChangeCredentialRequest + { + NewSecret = newPassword + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, target, newPassword); + await AssertPasswordRejectedAsync(factory, target, target.Secret); + + var after = await GetChainsAsync(factory, target.UserKey); + + after.Should().HaveCount(2); + after.Should().OnlyContain(c => c.IsRevoked == shouldRevoke); + + var adminChains = await GetChainsAsync(factory, admin.UserKey); + adminChains.Should().ContainSingle(c => !c.IsRevoked); + } + + [Fact] + public async Task AnonymousBeginReset_AlreadyActive_ShouldNotRevealResetState() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var first = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var notificationCount = notifier.Notifications.Count; + + var active = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + var unknown = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + $"unknown-{Guid.NewGuid():N}"); + + active.StatusCode.Should().Be(unknown.StatusCode); + + (await active.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + notifier.Notifications.Should().HaveCount(notificationCount); + } + + [Fact] + public async Task AnonymousCompleteReset_ExpiredToken_ShouldNotRevealResetState() + { + 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 notification = notifier.Notifications.Single(); + + factory.Clock.Advance( + notification.ExpiresAt - factory.Clock.UtcNow + + TimeSpan.FromSeconds(1)); + + var expired = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + notification.Token, + "Changed-Password-123!"); + + var unknown = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + $"unknown-{Guid.NewGuid():N}", + notification.Token, + "Changed-Password-123!"); + + expired.StatusCode.Should().Be(unknown.StatusCode); + + (await expired.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + await AssertPasswordAsync(factory, user, user.Secret); + } + + [Fact] + public async Task AnonymousBeginReset_InvalidCodeType_ShouldRejectConsistently() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + async Task SendAsync(string identifier) + { + return await client.PostAsJsonAsync( + "/auth/me/credentials/reset/begin", + new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = (ResetCodeType)999 + }); + } + + var existing = await SendAsync(user.Identifier); + + var unknown = await SendAsync( + $"unknown-{Guid.NewGuid():N}"); + + existing.StatusCode.Should().Be(HttpStatusCode.BadRequest); + unknown.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + using var existingJson = JsonDocument.Parse(await existing.Content.ReadAsStringAsync()); + using var unknownJson = JsonDocument.Parse(await unknown.Content.ReadAsStringAsync()); + + existingJson.RootElement.GetProperty("status").GetInt32() + .Should().Be(unknownJson.RootElement.GetProperty("status").GetInt32()); + + existingJson.RootElement.GetProperty("title").GetString() + .Should().Be(unknownJson.RootElement.GetProperty("title").GetString()); + + existingJson.RootElement.GetProperty("type").GetString() + .Should().Be(unknownJson.RootElement.GetProperty("type").GetString()); + + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task AnonymousCompleteReset_SamePassword_ShouldNotRevealCredentialState() + { + 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 samePassword = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + user.Secret); + + var unknown = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + $"unknown-{Guid.NewGuid():N}", + token, + user.Secret); + + samePassword.StatusCode.Should().Be(unknown.StatusCode); + + (await samePassword.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + await AssertPasswordAsync(factory, user, user.Secret); + } + private static HttpClient CreateClient(AuthServerFactory factory) @@ -540,4 +1090,16 @@ private static async Task GetPasswordCredentialAsync(AuthSer .OfType() .Single(); } + + private static async Task> GetChainsAsync(AuthServerFactory factory, UserKey userKey) + { + using var scope = factory.Services.CreateScope(); + + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + return await store.GetChainsByUserAsync(userKey); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs new file mode 100644 index 00000000..1e353d3d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs @@ -0,0 +1,260 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +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; + +/// +/// Real TestServer HTTP response contracts. Does not exercise the SDK request client/mapper. +/// +public sealed class SdkEndpointContractTests +{ + [Fact] + public async Task ChangeMyStatus_ShouldReturnTypedJson_NotEmpty200() + { + using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(); + using var client = CreateClient(factory); + await AuthenticateAsync(client, user); + using var response = await client.PostAsJsonAsync("/auth/me/status", + new ChangeUserStatusSelfRequest { NewStatus = SelfAssignableUserStatus.SelfSuspended }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Active); + result.CurrentStatus.Should().Be(UserStatus.SelfSuspended); + } + + [Fact] + public async Task ChangeUserStatus_ShouldReturnTypedJson_NotEmpty200() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/status", + new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Active); + result.CurrentStatus.Should().Be(UserStatus.Suspended); + } + + [Fact] + public async Task ChangeUserStatus_AlreadyInRequestedStatus_ShouldReturnTypedJson() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + var url = $"/auth/admin/users/{target.UserKey.Value}/status"; + var request = new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }; + using var first = await client.PostAsJsonAsync(url, request); + (await AssertTypedSuccessAsync(first)).IsSuccess.Should().BeTrue(); + using var second = await client.PostAsJsonAsync(url, request); + var result = await AssertTypedSuccessAsync(second); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Suspended); + result.CurrentStatus.Should().Be(UserStatus.Suspended); + } + + [Fact] + public async Task DeleteUser_ShouldReturnTypedJson_WithRequestedMode() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.DeleteAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/delete", + new DeleteUserRequest { Mode = DeleteMode.Soft }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.Mode.Should().Be(DeleteMode.Soft); + } + + [Fact] + public async Task AdminBeginReset_ShouldReturnTypedJson_WithoutProof() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + 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); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(target)); + var result = await AssertTypedSuccessAsync(response); + result.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); + var body = await response.Content.ReadAsStringAsync(); + var notification = notifier.Notifications.Should().ContainSingle(x => x.UserKey == target.UserKey).Subject; + body.Should().NotContain(notification.Token, "reset proof must only be sent through the notifier"); + } + + [Fact] + public async Task AdminCompleteReset_ShouldReturnTypedJson_AndChangeCredential() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + 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"; + using var begin = await client.PostAsJsonAsync($"{url}/begin", BeginResetRequest(target)); + await AssertTypedSuccessAsync(begin); + var token = notifier.Notifications.Single(x => x.UserKey == target.UserKey).Token; + var newSecret = $"Changed-{Guid.NewGuid():N}!"; + using var complete = await client.PostAsJsonAsync($"{url}/complete", + new CompleteResetCredentialRequest + { + Identifier = target.Identifier, + CredentialType = CredentialType.Password, + ResetToken = token, + NewSecret = newSecret + }); + var result = await AssertTypedSuccessAsync(complete); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task AdminBeginReset_TargetMismatch_ShouldReturn400_AndNotNotify() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + 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); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(other)); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task RevokeMy_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(); + using var client = CreateClient(factory); + await AuthenticateAsync(client, user); + using var response = await client.PostAsJsonAsync("/auth/me/credentials/revoke", + new RevokeCredentialRequest { Id = Guid.NewGuid() }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task RevokeAdmin_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.RevokeAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/revoke", + new RevokeCredentialRequest { Id = Guid.NewGuid() }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteCredentialAdmin_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.DeleteAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/delete", + new DeleteCredentialRequest { Id = Guid.NewGuid(), Mode = DeleteMode.Soft }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private static BeginResetCredentialRequest BeginResetRequest(IntegrationTestUser user) => new() + { + Identifier = user.Identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }; + + private static AuthServerFactory CreateResetFactory(CapturingResetNotifier notifier) => + AuthServerFactory.CreateWithServices(services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + + private static async Task AssertTypedSuccessAsync(HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.OK, + "FromJson requires a successful response with JSON content"); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var json = await response.Content.ReadAsStringAsync(); + json.Should().NotBeNullOrWhiteSpace(); + var value = await response.Content.ReadFromJsonAsync(); + value.Should().NotBeNull(); + return value!; + } + + private sealed class CapturingResetNotifier : 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 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", $"contract-{Guid.NewGuid():N}"); + return client; + } + + private static async Task AuthenticateAsync(HttpClient client, IntegrationTestUser user) + { + 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); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs index 9f9ab6d9..51a26819 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -65,19 +67,43 @@ public async Task AddUser_Should_Call_Admin_Endpoint() Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/add", It.IsAny()), Times.Once); } + [Fact] public async Task DeleteUser_Should_Call_Delete_Endpoint() { - Request.Setup(x => x.SendFormAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + var userKey = UserKey.FromString("user-1"); + + var request = new DeleteCredentialRequest + { + Id = Guid.NewGuid(), + Mode = DeleteMode.Hard + }; + + Request + .Setup(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/credentials/delete", + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 204 + }); var client = CreateCredentialClient(); - var userKey = UserKey.FromString("user-1"); - await client.Credentials.DeleteUserAsync(userKey, new DeleteCredentialRequest()); - Request.Verify(x => - x.SendFormAsync($"/auth/admin/users/{userKey.Value}/credentials/delete", - It.IsAny>(), - It.IsAny()), + + var result = await client.Credentials.DeleteUserAsync( + userKey, + request); + + result.IsSuccess.Should().BeTrue(); + + Request.Verify(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/credentials/delete", + It.Is(r => + r.Id == request.Id && + r.Mode == request.Mode), + It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index e7c280b1..47d17807 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -1,8 +1,7 @@ -using System.Text; -using System.Text.Json; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Auth; @@ -11,6 +10,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Moq; +using System.Text; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -550,6 +551,7 @@ public async Task DeleteAdminAsync_WhenAuthenticated_ForwardsDeleteModeAndReturn // Begin Reset - Admin // ========================================================= + [Fact] public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() { @@ -562,9 +564,6 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc f.SetJsonBody(new BeginResetCredentialRequest { - // Important: - // Admin endpoint's authorization target is userKey, - // not this identifier. Identifier = "alice@example.com", CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token @@ -575,24 +574,40 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc target.Value, access); + var expected = new BeginCredentialResetResult(); + f.Credentials .Setup(x => x.BeginResetAsync( access, It.IsAny(), f.HttpContext.RequestAborted)) - .ReturnsAsync(new BeginCredentialResetResult()); + .ReturnsAsync(expected); var result = await f.Sut.BeginResetAdminAsync( target, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.Credentials.Verify(x => x.BeginResetAsync( + access, + It.Is(r => + r.Identifier == "alice@example.com" && + r.CredentialType == CredentialType.Password && + r.ResetCodeType == ResetCodeType.Token), + f.HttpContext.RequestAborted), + Times.Once); } // ========================================================= // Complete Reset - Admin // ========================================================= + [Fact] public async Task CompleteResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() { @@ -616,18 +631,33 @@ public async Task CompleteResetAdminAsync_WhenAuthenticated_UsesTargetUserAsReso target.Value, access); + var expected = CredentialActionResult.Success(); + f.Credentials .Setup(x => x.CompleteResetAsync( access, It.IsAny(), f.HttpContext.RequestAborted)) - .ReturnsAsync(CredentialActionResult.Success()); + .ReturnsAsync(expected); var result = await f.Sut.CompleteResetAdminAsync( target, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.Credentials.Verify(x => x.CompleteResetAsync( + access, + It.Is(r => + r.Identifier == "alice@example.com" && + r.ResetToken == "token" && + r.NewSecret == "new-password"), + f.HttpContext.RequestAborted), + Times.Once); } // ========================================================= diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index c38605ea..891e3981 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -339,6 +340,8 @@ public async Task ChangeSecretAsync_Self_WhenValid_SavesAndRevokesOtherChains() It.IsAny(), It.IsAny()), Times.Never); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.SelfChange), Times.Once); } [Fact] @@ -376,6 +379,8 @@ public async Task ChangeSecretAsync_SelfWithoutActorChain_RevokesAllChains() Now, It.IsAny()), Times.Once); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.SelfChange), Times.Once); } [Fact] @@ -447,6 +452,8 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ Now, It.IsAny()), Times.Once); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.AdminChange), Times.Once); } // --------------------------------------------------------- @@ -972,6 +979,9 @@ private sealed class Fixture public Mock SessionStore { get; } = new(MockBehavior.Strict); + public Mock CredentialSessionPolicy { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1048,6 +1058,18 @@ public Fixture() .Setup(x => x.Create(It.IsAny())) .Returns(SessionStore.Object); + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.SelfChange)) + .Returns(CredentialSessionBehavior.PreserveCurrent); + + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.AdminChange)) + .Returns(CredentialSessionBehavior.RevokeAll); + + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.Recovery)) + .Returns(CredentialSessionBehavior.RevokeAll); + var options = Options.Create( TestServerOptions.Default()); @@ -1062,6 +1084,7 @@ public Fixture() IdentifierResolver.Object, SessionStoreFactory.Object, ResetNotifier.Object, + CredentialSessionPolicy.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 6b2190b9..0c8db7da 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -110,7 +110,7 @@ public async Task Reset_password_with_valid_token_should_succeed() } [Fact] - public async Task Reset_password_with_same_password_should_fail() + public async Task Reset_password_with_same_password_should_succeed_without_changing_password() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); @@ -118,12 +118,19 @@ public async Task Reset_password_with_same_password_should_fail() var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "admin")); + var result = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "admin")); + + result.IsSuccess.Should().BeTrue(); - await act.Should().ThrowAsync(); + // The reset token must be single-use even when + // the new password matches the existing password. + var secondResult = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "different-password")); + + secondResult.IsSuccess.Should().BeTrue(); } [Fact] @@ -194,32 +201,34 @@ public async Task Reset_token_should_be_single_use() } [Fact] - public async Task Reset_token_should_fail_if_expired() + public async Task Reset_token_should_not_change_password_if_expired() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); var notification = notifier.Notifications.Single(); - // Advance past the actual configured expiry. runtime.Clock.Advance( notification.ExpiresAt - runtime.Clock.UtcNow + TimeSpan.FromSeconds(1)); - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "newpass123")); + var result = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "newpass123")); - await act.Should().ThrowAsync(); + // Generic anonymous response, not proof of a password change. + result.IsSuccess.Should().BeTrue(); + + await AssertPasswordAsync(runtime, userKey, "admin"); + await AssertPasswordRejectedAsync(runtime, userKey, "newpass123"); } - private static ( - TestAuthRuntime Runtime, - RecordingCredentialResetNotifier Notifier) + private static (TestAuthRuntime Runtime, RecordingCredentialResetNotifier Notifier) CreateRuntime(int maxAttempts = 3) { var notifier = new RecordingCredentialResetNotifier(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 340e9dec..5868c2d6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1844,6 +1844,24 @@ 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>>(), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs index 497cae21..69e65095 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -290,17 +290,24 @@ public async Task ChangeStatusSelfAsync_UsesSelfActionAndActorUserKey() default)) .ReturnsAsync(accessContext); + var expected = UserStatusChangeResult.Success( + UserStatus.Active, + UserStatus.SelfSuspended); + f.Users .Setup(x => x.ChangeUserStatusAsync( accessContext, It.IsAny(), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); - var result = - await f.Sut.ChangeStatusSelfAsync(f.HttpContext); + var result = await f.Sut.ChangeStatusSelfAsync(f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.ChangeUserStatusAsync( accessContext, @@ -335,7 +342,6 @@ public async Task ChangeStatusAdminAsync_WhenUnauthenticated_ReturnsUnauthorized public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() { var f = CreateFixture(); - var targetUser = UserKey.New(); var request = new ChangeUserStatusAdminRequest @@ -345,8 +351,7 @@ public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() SetJsonBody(f.HttpContext, request); - var accessContext = - CreateAccessContext(f, targetUser); + var accessContext = CreateAccessContext(f, targetUser); f.AccessContextFactory .Setup(x => x.CreateAsync( @@ -358,18 +363,26 @@ public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() default)) .ReturnsAsync(accessContext); + var expected = UserStatusChangeResult.Success( + UserStatus.Active, + UserStatus.Suspended); + f.Users .Setup(x => x.ChangeUserStatusAsync( accessContext, It.IsAny(), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); var result = await f.Sut.ChangeStatusAdminAsync( targetUser, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.ChangeUserStatusAsync( accessContext, @@ -457,7 +470,7 @@ public async Task DeleteAsync_UsesDeleteAdminAction_TargetUser_AndDeleteModeAttr SetJsonBody(f.HttpContext, request); - AccessContext? capturedContext = null; + var accessContext = CreateAccessContext(f, targetUser); f.AccessContextFactory .Setup(x => x.CreateAsync( @@ -469,38 +482,36 @@ public async Task DeleteAsync_UsesDeleteAdminAction_TargetUser_AndDeleteModeAttr a.ContainsKey("deleteMode") && (DeleteMode)a["deleteMode"] == DeleteMode.Hard), default)) - .ReturnsAsync((AuthFlowContext _, - string _, - string _, - string? _, - IDictionary? _, - CancellationToken _) => - { - capturedContext = CreateAccessContext(f, targetUser); - return capturedContext; - }); + .ReturnsAsync(accessContext); + + var expected = UserDeleteResult.Success(DeleteMode.Hard); f.Users .Setup(x => x.DeleteUserAsync( - It.IsAny(), - It.Is(r => r.Mode == DeleteMode.Hard), + accessContext, + It.Is( + r => r.Mode == DeleteMode.Hard), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); var result = await f.Sut.DeleteAsync( targetUser, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.DeleteUserAsync( - It.IsAny(), - It.Is(r => r.Mode == DeleteMode.Hard), + accessContext, + It.Is( + r => r.Mode == DeleteMode.Hard), f.HttpContext.RequestAborted), Times.Once); } - // ============================================================ // Profiles // ============================================================ From e2cc7fcb1fe0dd12f0ae0b9089b16dd3d3da2c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:47:26 +0300 Subject: [PATCH 10/20] Fix Exception Flows (#68) * fix(server): map invalid JSON requests to validation errors * fix(server): normalize invalid request values to validation errors * fix(core): unify runtime exception status codes with HTTP responses fix(server): map resource reauthentication to challenge required * fix(client): normalize network and PKCE authorization errors * fix(security): propagate unexpected Argon2 verification failures --- .../Errors/Runtime/UAuthConflictException.cs | 2 + .../Errors/Runtime/UAuthForbiddenException.cs | 2 + .../Errors/Runtime/UAuthNotFoundException.cs | 2 +- .../Runtime/UAuthUnauthorizedException.cs | 2 + .../UAuthResourceAccessOrchestrator.cs | 4 +- .../HttpContext/HttpContextJsonExtensions.cs | 21 +- .../UAuthExceptionHandlingExtensions.cs | 36 +-- .../Redirect/ReturnUrlParser.cs | 8 +- .../Services/PkceService.cs | 5 +- .../TScripts/uauth.js | 54 +++- .../wwwroot/uauth.min.js | 2 +- .../Infrastructure/UAuthResultMapper.cs | 2 + .../Services/UAuthFlowClient.cs | 82 ++---- .../Argon2PasswordHasher.cs | 39 ++- .../HttpContextJsonContractTests.cs | 260 ++++++++++++++++++ .../ResourceApiAuthorizationTests.cs | 49 ++++ .../Client/UAuthFlowClientTests.cs | 3 +- .../Security/Argon2PasswordHasherTest.cs | 17 ++ .../Server/PkceServiceTests.cs | 25 +- .../Server/ReturnUrlParserTests.cs | 5 +- .../UAuthResourceAccessOrchestratorTests.cs | 40 ++- 21 files changed, 523 insertions(+), 137 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs index fd9de8bc..bcae50b8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConflictException.cs @@ -2,6 +2,8 @@ public sealed class UAuthConflictException : UAuthRuntimeException { + public override int StatusCode => 409; + public UAuthConflictException(string code) : base(code, "A resource conflict occurred.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs index 4c2cfb76..85a65fc9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthForbiddenException.cs @@ -2,6 +2,8 @@ public sealed class UAuthForbiddenException : UAuthRuntimeException { + public override int StatusCode => 403; + public UAuthForbiddenException(string code = "forbidden") : base(code, "Forbidden.") { } diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs index cc2468a0..4a6a6290 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs @@ -2,7 +2,7 @@ public class UAuthNotFoundException : UAuthRuntimeException { - public override int StatusCode => 400; + public override int StatusCode => 404; public override string Title => "The resource is not found."; diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs index 77e90e64..dc86071a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthUnauthorizedException.cs @@ -2,6 +2,8 @@ public sealed class UAuthUnauthorizedException : UAuthRuntimeException { + public override int StatusCode => 401; + public UAuthUnauthorizedException(string code = "unauthorized") : base(code, "Unauthorized.") { } diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs index 4774e393..b7451e38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs @@ -35,7 +35,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var decision = _authority.Decide(context, policies); if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + throw new UAuthChallengeRequiredException(); if (decision.IsDenied) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); @@ -53,7 +53,7 @@ public async Task ExecuteAsync(AccessContext context, IAccessC var decision = _authority.Decide(context, policies); if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); + throw new UAuthChallengeRequiredException(); if (decision.IsDenied) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs index 262f8aee..abefa272 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CodeBeam.UltimateAuth.Core.Errors; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Server.Extensions; @@ -15,10 +16,10 @@ public static async Task ReadJsonAsync(this HttpContext ctx, CancellationT var request = ctx.Request; if (!request.HasJsonContentType()) - throw new InvalidOperationException("Request content type must be application/json."); + throw new UAuthValidationException("request_content_type_invalid"); if (request.Body == null || request.ContentLength == 0) - throw new InvalidOperationException("Request body is empty."); + throw new UAuthValidationException("request_body_required"); request.EnableBuffering(); @@ -28,16 +29,24 @@ public static async Task ReadJsonAsync(this HttpContext ctx, CancellationT { var result = await JsonSerializer.DeserializeAsync(request.Body, JsonOptions, ct); - request.Body.Position = 0; - if (result == null) - throw new InvalidOperationException("Request body could not be deserialized."); + throw new UAuthValidationException("request_body_invalid"); return result; } catch (JsonException) { - throw new InvalidOperationException("Invalid JSON"); + throw new UAuthValidationException("request_json_invalid"); + } + // TODO: Remove this catch block in the future, as it is a workaround. + catch (ArgumentException ex) when (ex.ParamName == "value" && ex.Message.Contains("permission_required", StringComparison.Ordinal)) + { + throw new UAuthValidationException("permission_required"); + } + finally + { + if (request.Body.CanSeek) + request.Body.Position = 0; } } } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index 9ba89be2..d0567081 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -25,9 +25,11 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui }); } + // TODO: Standardize runtime error codes as stable machine-readable identifiers. + // Keep human-readable details separate from codes and ProblemDetails.Type. private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) { - var status = MapStatusCode(ex); + var status = ex.StatusCode; var problem = new ProblemDetails { @@ -45,20 +47,20 @@ private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeExcepti return context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json"); } - private static int MapStatusCode(UAuthRuntimeException ex) => - ex switch - { - UAuthAuthenticationException => StatusCodes.Status401Unauthorized, - UAuthAuthorizationException => StatusCodes.Status403Forbidden, - UAuthConflictException => StatusCodes.Status409Conflict, - UAuthValidationException => StatusCodes.Status400BadRequest, - UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, - UAuthForbiddenException => StatusCodes.Status403Forbidden, - UAuthNotFoundException => StatusCodes.Status404NotFound, - UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, - UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, - UAuthIdentifierConflictException => StatusCodes.Status409Conflict, - UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, - _ => StatusCodes.Status400BadRequest - }; + //private static int MapStatusCode(UAuthRuntimeException ex) => + // ex switch + // { + // UAuthAuthenticationException => StatusCodes.Status401Unauthorized, + // UAuthAuthorizationException => StatusCodes.Status403Forbidden, + // UAuthConflictException => StatusCodes.Status409Conflict, + // UAuthValidationException => StatusCodes.Status400BadRequest, + // UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, + // UAuthForbiddenException => StatusCodes.Status403Forbidden, + // UAuthNotFoundException => StatusCodes.Status404NotFound, + // UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, + // UAuthIdentifierNotFoundException => StatusCodes.Status404NotFound, + // UAuthIdentifierConflictException => StatusCodes.Status409Conflict, + // UAuthIdentifierValidationException => StatusCodes.Status400BadRequest, + // _ => StatusCodes.Status400BadRequest + // }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs index ba204e7a..2126605d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; internal static class ReturnUrlParser { @@ -22,8 +24,8 @@ public static ReturnUrlInfo Parse(string? returnUrl) } if (returnUrl.StartsWith("//", StringComparison.Ordinal)) - throw new InvalidOperationException("Invalid returnUrl."); + throw new UAuthValidationException("return_url_invalid"); - throw new InvalidOperationException("Invalid returnUrl."); + throw new UAuthValidationException("return_url_invalid"); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs index 44b15f1e..b14bccad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Flows; using CodeBeam.UltimateAuth.Server.Options; @@ -31,10 +32,10 @@ public PkceService(IAuthStore authStore, IPkceAuthorizationValidator validator, public async Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(command.CodeChallenge)) - throw new InvalidOperationException("code_challenge is required."); + throw new UAuthValidationException("code_challenge is required."); if (!string.Equals(command.ChallengeMethod, "S256", StringComparison.Ordinal)) - throw new InvalidOperationException("Only S256 supported."); + throw new UAuthValidationException("Only S256 supported."); var authorizationCode = AuthArtifactKey.New(); diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 01565046..8cdc2053 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -203,12 +203,27 @@ window.uauth.post = async function (options) { headers["Content-Type"] = "application/x-www-form-urlencoded"; } - const response = await fetch(url, { - method: "POST", - credentials: "include", - headers: headers, - body: body - }); + let response; + + try { + response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: body + }); + } catch (error) { + if (error instanceof TypeError) { + return { + ok: false, + status: 0, + refreshOutcome: null, + body: null + }; + } + + throw error; + } let responseBody = null; try { @@ -243,12 +258,27 @@ window.uauth.postJson = async function (options) { "X-Requested-With": "UAuth" }; - const response = await fetch(url, { - method: "POST", - credentials: "include", - headers: headers, - body: payload ? JSON.stringify(payload) : null - }); + let response; + + try { + response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: payload ? JSON.stringify(payload) : null + }); + } catch (error) { + if (error instanceof TypeError) { + return { + ok: false, + status: 0, + refreshOutcome: null, + body: null + }; + } + + throw error; + } let responseBody = null; try { 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 197576de..361da1fb 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: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 +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:h,data:t,clientProfile:e}=n;if(h==="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"}let i;try{i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r})}catch(s){if(s instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw s;}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:f,payload:r,clientProfile:e}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e??"","X-Requested-With":"UAuth"};let t;try{t=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r?JSON.stringify(r):null})}catch(u){if(u instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw u;}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 da59de93..17bc8eef 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -110,6 +110,8 @@ private static void EnsureTransport(UAuthTransportResult raw) }; } + // TODO: Preserve flattened ProblemDetails extensions during deserialization. + // Ensure custom extensions and traceId survive the server-to-SDK round-trip. private static UAuthProblem? TryDeserializeProblem(UAuthTransportResult raw) { if (raw.Body is null || raw.Body.Value.ValueKind != JsonValueKind.Object) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 42a889ee..41ede98f 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -186,55 +186,6 @@ public async Task RefreshAsync(bool isAuto = false) }; } - //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); @@ -304,14 +255,35 @@ public async Task BeginPkceAsync(string? returnUrl = null) ["challenge_method"] = "S256" }); - if (!raw.Ok || raw.Body is null) - throw new InvalidOperationException("PKCE authorize failed."); + EnsureValidHttpStatus(raw); - var response = raw.Body.Value.Deserialize( - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (raw.Status is >= 400 and < 500) + { + throw new UAuthProtocolException($"PKCE authorize request failed: {raw.Status}."); + } + + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new UAuthProtocolException("Missing PKCE authorize response body."); + } + + PkceAuthorizeResponse response; + + try + { + response = raw.Body.Value.Deserialize( + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new UAuthProtocolException("Invalid PKCE authorize response."); + } + catch (JsonException ex) + { + throw new UAuthProtocolException("Invalid PKCE authorize response format.", ex); + } - if (response is null || string.IsNullOrWhiteSpace(response.AuthorizationCode)) - throw new InvalidOperationException("Invalid PKCE authorize response."); + if (string.IsNullOrWhiteSpace(response.AuthorizationCode)) + throw new UAuthProtocolException("PKCE authorize response has no authorization code."); if (pkce.OnAuthorized is not null) await pkce.OnAuthorized(response); diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs index f72fa2ae..800d9d68 100644 --- a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -49,27 +49,36 @@ public bool Verify(PasswordHash hash, string secret) !int.TryParse(parts[2], out var parallelism)) return false; - try - { - var salt = Convert.FromBase64String(parts[3]); - var expectedHash = Convert.FromBase64String(parts[4]); - - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) - { - Salt = salt, - Iterations = iterations, - MemorySize = memory, - DegreeOfParallelism = parallelism - }; + if (iterations <= 0 || memory <= 0 || parallelism <= 0) + return false; - var actualHash = argon2.GetBytes(expectedHash.Length); + byte[] salt; + byte[] expectedHash; - return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + try + { + salt = Convert.FromBase64String(parts[3]); + expectedHash = Convert.FromBase64String(parts[4]); } - catch + catch (FormatException) { return false; } + + if (salt.Length == 0 || expectedHash.Length == 0) + return false; + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(secret)) + { + Salt = salt, + Iterations = iterations, + MemorySize = memory, + DegreeOfParallelism = parallelism + }; + + var actualHash = argon2.GetBytes(expectedHash.Length); + + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); } public bool NeedsRehash(PasswordHash hash) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs new file mode 100644 index 00000000..c43bdac8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/HttpContextJsonContractTests.cs @@ -0,0 +1,260 @@ +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class HttpContextJsonContractTests +{ + private const string Endpoint = "/test/json-contract"; + + public static TheoryData InvalidJsonRequests => new() + { + { + "text/plain", + """{"name":"test"}""", + "request_content_type_invalid" + }, + { + "application/json", + "", + "request_body_required" + }, + { + "application/json", + "null", + "request_body_invalid" + }, + { + "application/json", + "{", + "request_json_invalid" + }, + { + "application/json", + "{}", + "request_json_invalid" + } + }; + + [Theory] + [MemberData(nameof(InvalidJsonRequests))] + public async Task InvalidJsonRequest_ShouldReturn400ProblemDetails( + string contentType, + string body, + string expectedCode) + { + await using var host = await JsonTestHost.CreateAsync(); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + Endpoint); + + request.Content = new StringContent( + body, + Encoding.UTF8); + + request.Content.Headers.ContentType = + MediaTypeHeaderValue.Parse(contentType); + + using var response = await host.Client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(400); + problem.Detail.Should().Be(expectedCode); + problem.Type.Should().Contain(expectedCode); + problem.Extensions.Should().ContainKey("traceId"); + } + + [Fact] + public async Task ValidJsonRequest_ShouldReturn200WithTypedResult() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var response = await host.Client.PostAsJsonAsync( + Endpoint, + new { name = "UltimateAuth" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Name.Should().Be("UltimateAuth"); + } + + [Fact] + public async Task MissingContentType_ShouldReturn400ProblemDetails() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + Endpoint) + { + Content = new ByteArrayContent( + Encoding.UTF8.GetBytes("""{"name":"test"}""")) + }; + + using var response = await host.Client.SendAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem!.Detail.Should().Be("request_content_type_invalid"); + } + + [Fact] + public async Task JsonReader_ShouldRewindBodyAfterSuccess() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var response = await host.Client.PostAsJsonAsync( + "/test/json-rewind", + new { name = "UltimateAuth" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Name.Should().Be("UltimateAuth"); + result.BodyStartsWithJson.Should().BeTrue(); + } + + [Fact] + public async Task InvalidPermission_ShouldReturn400ProblemDetails() + { + await using var host = await JsonTestHost.CreateAsync(); + + using var content = new StringContent( + """ + { + "name": "TestRole", + "permissions": [ + { "value": "" } + ] + } + """, + Encoding.UTF8, + "application/json"); + + using var response = await host.Client.PostAsync( + "/test/json-permission", + content); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(400); + problem.Detail.Should().Be("permission_required"); + } + + + private sealed class JsonTestHost : IAsyncDisposable + { + private readonly WebApplication _app; + + public HttpClient Client { get; } + + private JsonTestHost(WebApplication app) + { + _app = app; + Client = app.GetTestClient(); + } + + public static async Task CreateAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + + app.UseUAuthExceptionHandling(); + + app.MapPost(Endpoint, async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + return Results.Ok(new JsonTestResult(request.Name)); + }); + + app.MapPost("/test/json-rewind", async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + var firstByte = ctx.Request.Body.ReadByte(); + + return Results.Ok(new BodyRewindResult( + request.Name, + firstByte == '{')); + }); + + + app.MapPost("/test/json-permission", async (HttpContext ctx) => + { + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); + + return Results.Ok(request.Name); + }); + + await app.StartAsync(); + + return new JsonTestHost(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private sealed class JsonTestRequest + { + public required string Name { get; init; } + } + + private sealed record JsonTestResult(string Name); + + private sealed class PermissionTestRequest + { + public required string Name { get; init; } + + public required Permission[] Permissions { get; init; } + } + + private sealed record BodyRewindResult( + string Name, + bool BodyStartsWithJson); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs index 271e73a1..81dd27c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ResourceApi/ResourceApiAuthorizationTests.cs @@ -7,10 +7,12 @@ using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Server; using FluentAssertions; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Moq; using System.Net; using System.Net.Http.Headers; +using System.Net.Http.Json; namespace CodeBeam.UltimateAuth.Tests.Integration.ResourceApi; @@ -321,6 +323,53 @@ await CreateHostAsync( .Be("products"); } + [Fact] + public async Task PolicyEndpoint_WhenReauthenticationRequired_ShouldReturn401ProblemDetails() + { + var validator = CreateValidValidator( + ("uauth:permission", TestResourceController.ProductsRead)); + + var authority = new Mock(); + + authority + .Setup(x => x.Decide( + It.IsAny(), + It.IsAny>())) + .Returns(AccessDecision.ReauthenticationRequired()); + + var policyProvider = CreatePolicyProvider(); + + await using var host = await CreateHostAsync( + validator.Object, + authority.Object, + policyProvider.Object); + + AddSession(host.Client); + + using var response = await host.Client.GetAsync( + "/__tests/resource/products/read"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + response.Content.Headers.ContentType! + .MediaType.Should().Be("application/problem+json"); + + var problem = await response.Content + .ReadFromJsonAsync(); + + problem.Should().NotBeNull(); + problem!.Status.Should().Be(401); + problem.Detail.Should().Be("challenge_required"); + problem.Type.Should().EndWith("/challenge_required"); + + authority.Verify( + x => x.Decide( + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private static async Task CreateHostAsync( ISessionValidator validator, IAccessAuthority authority, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index bc65d66a..830b225b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -515,8 +515,7 @@ public async Task BeginPkce_Should_Throw_When_AuthorizationCode_Missing() Func act = async () => await client.BeginPkceAsync(); - await act.Should().ThrowAsync() - .WithMessage("*Invalid PKCE authorize response*"); + await act.Should().ThrowAsync().WithMessage("*authorization code*"); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs index ea9342a7..e2e1414f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -291,6 +291,23 @@ public void NeedsRehash_Should_Return_True_When_Parallelism_Changed() .BeTrue(); } + [Theory] + [InlineData("0.65536.1.c2FsdA==.aGFzaA==")] + [InlineData("3.0.1.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.0.c2FsdA==.aGFzaA==")] + [InlineData("3.65536.1..aGFzaA==")] + [InlineData("3.65536.1.c2FsdA==.")] + public void Verify_ShouldReturnFalse_ForMalformedHash(string encoded) + { + var hasher = CreateHasher(); + + var hash = PasswordHash.Create( + PasswordAlgorithms.Argon2, + encoded); + + hasher.Verify(hash, "password123").Should().BeFalse(); + } + private static Argon2PasswordHasher CreateHasher() { return CreateHasher(new Argon2Options()); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs index 7c83cf0e..7246bf2f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceServiceTests.cs @@ -3,6 +3,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.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Auth; @@ -32,19 +33,15 @@ public sealed class PkceServiceTests [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task AuthorizeAsync_WhenCodeChallengeIsMissing_ShouldReject( - string? codeChallenge) + public async Task AuthorizeAsync_WhenCodeChallengeIsMissing_ShouldReject(string? codeChallenge) { var fixture = CreateFixture(); - var command = CreateAuthorizeCommand( - codeChallenge: codeChallenge!); + var command = CreateAuthorizeCommand(codeChallenge: codeChallenge!); - var act = () => - fixture.Sut.AuthorizeAsync(command); + var act = () => fixture.Sut.AuthorizeAsync(command); - await act.Should() - .ThrowAsync(); + await act.Should().ThrowAsync(); fixture.Store.Verify( x => x.StoreAsync( @@ -59,19 +56,15 @@ await act.Should() [InlineData("plain")] [InlineData("s256")] [InlineData("S512")] - public async Task AuthorizeAsync_WhenChallengeMethodIsNotExactS256_ShouldReject( - string challengeMethod) + public async Task AuthorizeAsync_WhenChallengeMethodIsNotExactS256_ShouldReject(string challengeMethod) { var fixture = CreateFixture(); - var command = CreateAuthorizeCommand( - challengeMethod: challengeMethod); + var command = CreateAuthorizeCommand(challengeMethod: challengeMethod); - var act = () => - fixture.Sut.AuthorizeAsync(command); + var act = () => fixture.Sut.AuthorizeAsync(command); - await act.Should() - .ThrowAsync(); + await act.Should().ThrowAsync(); fixture.Store.Verify( x => x.StoreAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs index e3968775..71b34e5c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -40,6 +41,6 @@ public void Absolute_Https_Url_Is_Treated_As_Absolute() public void Parser_Rejects_Unsafe_Schemes(string returnUrl) { Action act = () => ReturnUrlParser.Parse(returnUrl); - act.Should().Throw().WithMessage("*Invalid returnUrl*"); + act.Should().Throw().Which.Code.Should().Be("return_url_invalid"); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs index 17f2f0b2..d005bf41 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs @@ -173,9 +173,7 @@ public async Task ExecuteAsync_WhenReauthenticationRequired_ShouldNotExecuteComm var act = () => _sut.ExecuteAsync(context, command); - await act.Should() - .ThrowAsync() - .WithMessage("*reauthentication*"); + await act.Should().ThrowAsync(); executed.Should().BeFalse(); } @@ -391,6 +389,42 @@ await act.Should() Times.Never); } + [Fact] + public async Task ExecuteAsyncOfT_WhenReauthenticationRequired_ShouldNotExecuteCommand() + { + var policies = Array.Empty(); + + _policyProvider + .Setup(x => x.GetPolicies(It.IsAny())) + .Returns(policies); + + _authority + .Setup(x => x.Decide( + It.IsAny(), + policies)) + .Returns(AccessDecision.ReauthenticationRequired()); + + var executed = false; + + var command = new AccessCommand(_ => + { + executed = true; + return Task.FromResult("result"); + }); + + Func act = async () => + await _sut.ExecuteAsync(CreateContext(), command); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("challenge_required"); + exception.Which.StatusCode.Should().Be(401); + + executed.Should().BeFalse(); + } + + private static ClaimsPrincipal CreatePrincipal( params (string Type, string Value)[] claims) { From 34e07f6650e6b8b9d36118b58b266e3a808a220f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:10:22 +0300 Subject: [PATCH 11/20] Remove Original Options from AuthFlowContext and Refactor Session Validation, Fix Mode Based Options not Using (#69) --- .../Session/SessionIssuanceContext.cs | 2 + .../Session/SessionRotationContext.cs | 2 + .../Session/SessionValidationContext.cs | 6 + .../Auth/Context/AuthFlowContext.cs | 9 +- .../Auth/Context/AuthFlowContextFactory.cs | 40 +++--- .../Auth/EffectiveServerOptionsProvider.cs | 8 +- .../Response/EffectiveAuthModeResolver.cs | 2 +- .../Response/IEffectiveAuthModeResolver.cs | 2 +- .../AspNetCore/UAuthAuthenticationHandler.cs | 1 + .../UAuthResourceAuthenticationHandler.cs | 2 + .../Endpoints/PkceEndpointHandler.cs | 2 +- .../Endpoints/RefreshEndpointHandler.cs | 2 +- .../Endpoints/ValidateEndpointHandler.cs | 3 +- .../UAuthApplicationBuilderExtensions.cs | 2 +- .../Flows/Login/LoginOrchestrator.cs | 22 ++- .../Cookies/UAuthCookiePolicyBuilder.cs | 10 +- .../Issuers/UAuthSessionIssuer.cs | 31 +++-- .../Issuers/UAuthTokenIssuer.cs | 4 +- .../Redirect/AuthRedirectResolver.cs | 8 +- ...s => RemoteSessionValidationMiddleware.cs} | 44 +++--- .../Options/Defaults/ConfigureDefaults.cs | 1 - .../IEffectiveServerOptionsProvider.cs | 4 +- .../UAuthServerSessionOptionsValidator.cs | 3 +- .../Services/RefreshFlowService.cs | 13 +- .../Services/RefreshTokenRotationService.cs | 4 +- .../Services/UAuthSessionValidator.cs | 12 +- .../Credentials/ChangePasswordTests.cs | 2 - .../Helpers/AuthFlowTestFactory.cs | 3 +- .../Helpers/TestAuthModeResolver.cs | 3 +- .../RemoteSessionValidatorTests.cs | 7 +- .../Server/CookieInfrastructureTests.cs | 28 ++-- .../Server/EffectiveAuthModeResolverTests.cs | 2 +- .../EffectiveServerOptionsProviderTests.cs | 36 ++--- .../Server/LogoutEndpointHandlerTests.cs | 6 +- .../Server/PkceEndpointHandlerTests.cs | 5 +- .../Server/RedirectTests.cs | 12 +- .../Server/RefreshFlowServiceTests.cs | 3 +- .../RefreshTokenRotationServiceTests.cs | 3 +- .../Server/SessionEndpointHandlerTests.cs | 3 +- .../Server/UAuthFlowServiceTests.cs | 3 +- ...UAuthResourceAuthenticationHandlerTests.cs | 18 ++- .../Server/UAuthSessionValidatorTests.cs | 6 +- .../Sessions/UAuthSessionIssuerTests.cs | 125 +++++++++--------- .../Tokens/UAuthTokenIssuerTests.cs | 47 ++++++- 44 files changed, 291 insertions(+), 260 deletions(-) rename src/CodeBeam.UltimateAuth.Server/Middlewares/{SessionValidationMiddleware.cs => RemoteSessionValidationMiddleware.cs} (51%) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs index 1b7886a7..65e11a33 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Core.Contracts; @@ -16,6 +17,7 @@ public sealed class SessionIssuanceContext public ClaimsSnapshot? Claims { get; init; } public required SessionMetadata Metadata { get; init; } public required UAuthMode Mode { get; init; } + public required UAuthSessionOptions SessionOptions { get; init; } /// /// Optional chain identifier. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs index 3be8b529..9543328d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Core.Contracts; @@ -13,4 +14,5 @@ public sealed record SessionRotationContext public ClaimsSnapshot? Claims { get; init; } public required SessionMetadata Metadata { get; init; } = SessionMetadata.Empty; public required UAuthMode Mode { get; init; } + public required UAuthSessionOptions SessionOptions { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs index 6a493e06..da29eb5f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; namespace CodeBeam.UltimateAuth.Core.Contracts; @@ -9,4 +10,9 @@ public sealed record SessionValidationContext public AuthSessionId SessionId { get; init; } public DateTimeOffset Now { get; init; } public required DeviceContext Device { get; init; } + + /// + /// Required session options for validation. Remote session validators may not include. + /// + public UAuthSessionOptions? SessionOptions { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index acef799a..ad1f8c9c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -21,8 +21,7 @@ public sealed class AuthFlowContext public bool IsAuthenticated { get; } public UserKey? UserKey { get; } - public UAuthServerOptions OriginalOptions { get; } - public EffectiveUAuthServerOptions EffectiveOptions { get; } + public UAuthServerOptions Options { get; } public EffectiveAuthResponse Response { get; } public ReturnUrlInfo ReturnUrlInfo { get; } @@ -46,8 +45,7 @@ internal AuthFlowContext( bool isAuthenticated, UserKey? userKey, SessionSecurityContext? session, - UAuthServerOptions originalOptions, - EffectiveUAuthServerOptions effectiveOptions, + UAuthServerOptions options, EffectiveAuthResponse response, PrimaryTokenKind primaryTokenKind, ReturnUrlInfo returnUrlInfo) @@ -65,8 +63,7 @@ internal AuthFlowContext( IsAuthenticated = isAuthenticated; UserKey = userKey; - OriginalOptions = originalOptions; - EffectiveOptions = effectiveOptions; + Options = options; Response = response; PrimaryTokenKind = primaryTokenKind; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 0172602d..94069813 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -2,7 +2,6 @@ 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; @@ -50,19 +49,20 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var user = ctx.GetUserContext(); var clientProfile = await _clientProfileReader.ReadAsync(ctx); - var originalOptions = _serverOptionsProvider.GetOriginal(ctx); - var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); - var allowedModes = originalOptions.AllowedModes; + var effective = _serverOptionsProvider.GetEffective(ctx, clientProfile); + var options = effective.Options; - if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + var effectiveMode = effective.Mode; + var allowedModes = options.AllowedModes; + + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveMode)) { - throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + throw new InvalidOperationException($"Auth mode '{effectiveMode}' is not allowed by server configuration."); } - var effectiveMode = effectiveOptions.Mode; var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); - var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effective); var deviceInfo = await _deviceResolver.ResolveAsync(ctx); var deviceContext = _deviceContextFactory.Create(deviceInfo); var returnUrl = await ctx.GetReturnUrlAsync(); @@ -78,6 +78,7 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp Tenant = tenant, SessionId = sessionCtx.SessionId!.Value, Device = deviceContext, + SessionOptions = options.Session, Now = _clock.UtcNow }, ct); @@ -100,8 +101,7 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp user?.IsAuthenticated ?? false, user?.UserId, sessionSecurityContext, - originalOptions, - effectiveOptions, + options, response, primaryTokenKind, returnUrlInfo @@ -113,19 +113,19 @@ public async ValueTask RecreateWithClientProfileAsync(AuthFlowC var flowType = existing.FlowType; var tenant = existing.Tenant; - var originalOptions = existing.OriginalOptions; - var effectiveOptions = _serverOptionsProvider.GetEffective(tenant, flowType, overriddenProfile); + var effective = _serverOptionsProvider.GetEffective(tenant, overriddenProfile); + var options = effective.Options; + var effectiveMode = effective.Mode; - var allowedModes = originalOptions.AllowedModes; + var allowedModes = options.AllowedModes; - if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveMode)) { - throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + throw new InvalidOperationException($"Auth mode '{effectiveMode}' is not allowed by server configuration."); } - var effectiveMode = effectiveOptions.Mode; var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); - var response = _authResponseResolver.Resolve(effectiveMode, flowType, overriddenProfile, effectiveOptions); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, overriddenProfile, effective); var returnUrlInfo = existing.ReturnUrlInfo; var deviceContext = existing.Device; @@ -140,8 +140,7 @@ public async ValueTask RecreateWithClientProfileAsync(AuthFlowC existing.IsAuthenticated, existing.UserKey, session, - originalOptions, - effectiveOptions, + options, response, primaryTokenKind, returnUrlInfo @@ -162,8 +161,7 @@ public ValueTask RecreateWithDeviceAsync(AuthFlowContext existi existing.IsAuthenticated, existing.UserKey, existing.Session, - existing.OriginalOptions, - existing.EffectiveOptions, + existing.Options, existing.Response, existing.PrimaryTokenKind, existing.ReturnUrlInfo diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs index 4d3052ca..cb2528ab 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs @@ -24,16 +24,16 @@ public UAuthServerOptions GetOriginal(HttpContext context) return _baseOptions.Value; } - public EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile) + public EffectiveUAuthServerOptions GetEffective(HttpContext context, UAuthClientProfile clientProfile) { var tenant = context.GetTenant(); - return GetEffective(tenant, flowType, clientProfile); + return GetEffective(tenant, clientProfile); } - public EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile) + public EffectiveUAuthServerOptions GetEffective(TenantKey tenant, UAuthClientProfile clientProfile) { var original = _baseOptions.Value; - var effectiveMode = _modeResolver.Resolve(clientProfile, flowType); + var effectiveMode = _modeResolver.Resolve(clientProfile); var options = original.Clone(); ConfigureDefaults.ApplyModeDefaults(effectiveMode, options); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs index 90865473..fa08ca38 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Server.Auth; internal sealed class EffectiveAuthModeResolver : IEffectiveAuthModeResolver { - public UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType) + public UAuthMode Resolve(UAuthClientProfile clientProfile) { return clientProfile switch { diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs index 71969091..8af87eee 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -6,5 +6,5 @@ namespace CodeBeam.UltimateAuth.Server.Auth; public interface IEffectiveAuthModeResolver { - UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType); + UAuthMode Resolve(UAuthClientProfile clientProfile); } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index 96fff082..fd8d351d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -61,6 +61,7 @@ protected override async Task HandleAuthenticateAsync() Tenant = tenant, SessionId = sessionId, Device = _deviceContextFactory.Create(credential.Device), + SessionOptions = _serverOptions.Session, Now = _clock.UtcNow }); diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs index a479aa56..6e8a32a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 519dfbe2..e851f13f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -297,7 +297,7 @@ public async Task CompleteAsync(HttpContext ctx) private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthFlowContext auth, string error) { - var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login"; + var basePath = auth.Options.Hub.LoginPath ?? "/login"; var hubKey = await ResolveHubKeyAsync(ctx); if (!string.IsNullOrWhiteSpace(hubKey)) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index 2d4f37ee..6f1ff5cf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -72,7 +72,7 @@ public async Task RefreshAsync(HttpContext ctx) _credentialWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); } - if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) + if (flow.Options.Diagnostics.EnableRefreshDetails) { _refreshWriter.Write(ctx, result.Outcome); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index 84f22cde..25726069 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -67,7 +67,8 @@ public async Task ValidateAsync(HttpContext context, CancellationToken Tenant = tenant, SessionId = sessionId, Now = _clock.UtcNow, - Device = auth.Device + Device = auth.Device, + SessionOptions = auth.Options.Session }, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index d352d7f1..5d806c85 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -60,7 +60,7 @@ public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBu app.UseUAuthExceptionHandling(); app.UseMiddleware(); app.UseMiddleware(); - app.UseMiddleware(); + app.UseMiddleware(); app.UseMiddleware(); return app; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index a8b8f377..425e450b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -30,7 +30,6 @@ internal sealed class LoginOrchestrator : ILoginOrchestrator, IInternalLoginOrch private readonly ISessionStoreFactory _storeFactory; private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk private readonly UAuthEventDispatcher _events; - private readonly UAuthServerOptions _options; private readonly IClock _clock; public LoginOrchestrator( @@ -44,7 +43,6 @@ public LoginOrchestrator( ISessionStoreFactory storeFactory, IAuthenticationSecurityManager authenticationSecurityManager, UAuthEventDispatcher events, - IOptions options, IClock clock) { _identifierResolver = identifierResolver; @@ -57,7 +55,6 @@ public LoginOrchestrator( _storeFactory = storeFactory; _authenticationSecurityManager = authenticationSecurityManager; _events = events; - _options = options.Value; _clock = clock; } @@ -136,7 +133,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (chain is not null) { - var chainState = chain.GetState(now, _options.Session.IdleTimeout); + var chainState = chain.GetState(now, flow.Options.Session.IdleTimeout); if (chainState == SessionState.Active) { @@ -174,14 +171,14 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req request.CredentialType, state => state.RegisterFailure( now, - _options.Login.MaxFailedAttempts, - _options.Login.LockoutDuration, - _options.Login.FailureWindow, - _options.Login.ExtendLockOnFailure), + flow.Options.Login.MaxFailedAttempts, + flow.Options.Login.LockoutDuration, + flow.Options.Login.FailureWindow, + flow.Options.Login.ExtendLockOnFailure), ct); } - if (_options.Login.IncludeFailureDetails) + if (flow.Options.Login.IncludeFailureDetails) { var stateForResponse = factorState; @@ -190,9 +187,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req lockedUntil = stateForResponse.LockedUntil; remainingAttempts = 0; } - else if (_options.Login.MaxFailedAttempts > 0) + else if (flow.Options.Login.MaxFailedAttempts > 0) { - remainingAttempts = _options.Login.MaxFailedAttempts - stateForResponse.FailedAttempts; + remainingAttempts = flow.Options.Login.MaxFailedAttempts - stateForResponse.FailedAttempts; } } @@ -247,7 +244,8 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req Claims = claims, ChainId = chainId, Metadata = SessionMetadata.Empty, - Mode = flow.EffectiveMode + Mode = flow.EffectiveMode, + SessionOptions = flow.Options.Session }; var authContext = flow.ToAuthContext(now); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs index 78b0af0a..1b52fb59 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -34,7 +34,7 @@ private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowC if (cookie.SameSite is not null) return cookie.SameSite.Value; - return context.OriginalOptions.HubDeploymentMode switch + return context.Options.HubDeploymentMode switch { UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, @@ -65,16 +65,16 @@ private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, return kind switch { GrantKind.Session => ResolveSessionLifetime(context), - GrantKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, - GrantKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, + GrantKind.RefreshToken => context.Options.Token.RefreshTokenLifetime, + GrantKind.AccessToken => context.Options.Token.AccessTokenLifetime, _ => null }; } private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context) { - var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout; - var refresh = context.EffectiveOptions.Options.Token.RefreshTokenLifetime; + var sessionIdle = context.Options.Session.IdleTimeout; + var refresh = context.Options.Token.RefreshTokenLifetime; return context.EffectiveMode switch { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index a37ca6d6..e2ac81a0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -4,8 +4,6 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; using System.Security; namespace CodeBeam.UltimateAuth.Server.Infrastructure; @@ -14,33 +12,32 @@ public sealed class UAuthSessionIssuer : ISessionIssuer { private readonly ISessionStoreFactory _storeFactory; private readonly IOpaqueTokenGenerator _opaqueGenerator; - private readonly UAuthServerOptions _options; - public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator) { _storeFactory = storeFactory; _opaqueGenerator = opaqueGenerator; - _options = options.Value; } public async Task IssueSessionAsync(SessionIssuanceContext context, CancellationToken ct = default) { // Defensive guard — enforcement belongs to Authority if (context.Mode == UAuthMode.PureJwt) - { throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode."); - } + + var sessionOptions = context.SessionOptions; var now = context.Now; var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId)) throw new InvalidCastException("Can't create opaque id."); - var expiresAt = now.Add(_options.Session.Lifetime); + var expiresAt = now.Add(sessionOptions.Lifetime); - if (_options.Session.MaxLifetime is not null) + if (sessionOptions.MaxLifetime is not null) { - var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value); if (absoluteExpiry < expiresAt) expiresAt = absoluteExpiry; } @@ -94,7 +91,7 @@ await kernel.ExecuteAsync(async _ => //chain = await kernel.GetChainAsync(context.ChainId.Value) // ?? throw new UAuthNotFoundException("Chain not found."); - var chainState = chain.GetState(now, _options.Session.IdleTimeout); + var chainState = chain.GetState(now, sessionOptions.IdleTimeout); if (chainState != SessionState.Active) throw new UAuthValidationException("Chain is not active."); @@ -124,12 +121,12 @@ await kernel.ExecuteAsync(async _ => var sessions = await kernel.GetSessionsByChainAsync(chain.ChainId); - if (sessions.Count >= _options.Session.MaxSessionsPerChain) + if (sessions.Count >= sessionOptions.MaxSessionsPerChain) { var toDelete = sessions .Where(s => s.SessionId != chain.ActiveSessionId) .OrderBy(x => x.CreatedAt) - .Take(sessions.Count - _options.Session.MaxSessionsPerChain + 1) + .Take(sessions.Count - sessionOptions.MaxSessionsPerChain + 1) .ToList(); foreach (var old in toDelete) @@ -175,14 +172,16 @@ public async Task RotateSessionAsync(SessionRotationContext conte var kernel = _storeFactory.Create(context.Tenant); var now = context.Now; + var sessionOptions = context.SessionOptions; + var opaqueSessionId = _opaqueGenerator.Generate(); if (!AuthSessionId.TryCreate(opaqueSessionId, out var newSessionId)) throw new InvalidCastException("Can't create opaque session id."); - var expiresAt = now.Add(_options.Session.Lifetime); - if (_options.Session.MaxLifetime is not null) + var expiresAt = now.Add(sessionOptions.Lifetime); + if (sessionOptions.MaxLifetime is not null) { - var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value); if (absoluteExpiry < expiresAt) expiresAt = absoluteExpiry; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 1649494c..36bdbd27 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -32,7 +32,7 @@ public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerato public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) { - var tokens = flow.OriginalOptions.Token; + var tokens = flow.Options.Token; var now = _clock.UtcNow; var expires = now.Add(tokens.AccessTokenLifetime); @@ -59,7 +59,7 @@ UAuthMode.SemiHybrid or return null; var now = _clock.UtcNow; - var expires = now.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); + var expires = now.Add(flow.Options.Token.RefreshTokenLifetime); var raw = _opaqueGenerator.Generate(); var hash = _tokenHasher.Hash(raw); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs index 68b2e7a5..afddbead 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -44,13 +44,13 @@ private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? if (info.IsAbsolute && (info.AbsoluteUri!.Scheme == Uri.UriSchemeHttp || info.AbsoluteUri!.Scheme == Uri.UriSchemeHttps)) { var origin = info.AbsoluteUri!.GetLeftPart(UriPartial.Authority); - ValidateAllowed(origin, flow.OriginalOptions); + ValidateAllowed(origin, flow.Options); return RedirectDecision.To(info.AbsoluteUri.ToString()); } if (!string.IsNullOrWhiteSpace(info.RelativePath)) { - var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + var baseAddress = _baseAddressResolver.Resolve(ctx, flow.Options); return RedirectDecision.To(UrlComposer.Combine(baseAddress, info.RelativePath)); } } @@ -58,7 +58,7 @@ private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? if (string.IsNullOrWhiteSpace(fallbackPath)) return RedirectDecision.None(); - var baseUrl = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + var baseUrl = _baseAddressResolver.Resolve(ctx, flow.Options); var query = new Dictionary(); @@ -76,7 +76,7 @@ private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? Reason = failureReason }; - if (flow.FlowType == AuthFlowType.Login && loginResult is not null && flow.OriginalOptions.Login.IncludeFailureDetails) + if (flow.FlowType == AuthFlowType.Login && loginResult is not null && flow.Options.Login.IncludeFailureDetails) { payload = payload with { diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/RemoteSessionValidationMiddleware.cs similarity index 51% rename from src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs rename to src/CodeBeam.UltimateAuth.Server/Middlewares/RemoteSessionValidationMiddleware.cs index 17cf6471..cffb189e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/RemoteSessionValidationMiddleware.cs @@ -3,25 +3,27 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Server.Middlewares; -public class SessionValidationMiddleware +public class RemoteSessionValidationMiddleware { private readonly RequestDelegate _next; - private readonly ISessionValidator _validator; private readonly IClock _clock; - public SessionValidationMiddleware(RequestDelegate next, ISessionValidator validator, IClock clock) + public RemoteSessionValidationMiddleware(RequestDelegate next, IClock clock) { _next = next; - _validator = validator; _clock = clock; } - public async Task Invoke(HttpContext context) + public async Task InvokeAsync(HttpContext context, ISessionValidator validator) { var sessionCtx = context.GetSessionContext(); @@ -33,19 +35,31 @@ public async Task Invoke(HttpContext context) return; } - var info = await context.GetDeviceAsync(); - var device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress); - if (sessionCtx.Tenant is not TenantKey tenant) throw new InvalidOperationException("Tenant is not resolved."); - var result = await _validator.ValidateSessionAsync(new SessionValidationContext - { - Tenant = tenant, - SessionId = sessionCtx.SessionId!.Value, - Now = _clock.UtcNow, - Device = device - }); + if (sessionCtx.SessionId is not AuthSessionId sessionId) + throw new InvalidOperationException("Session ID is not resolved."); + + var info = await context.GetDeviceAsync(); + + var device = DeviceContext.Create( + info.DeviceId, + info.DeviceType, + info.Platform, + info.OperatingSystem, + info.Browser, + info.IpAddress); + + var result = await validator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionId, + Now = _clock.UtcNow, + Device = device + }, + context.RequestAborted); context.Items[UAuthConstants.HttpItems.SessionValidationResult] = result; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index 9b8d9f6c..abb1a703 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -49,7 +49,6 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) // Hard re-auth boundary is an advanced security feature // Do NOT enable by default s.MaxLifetime ??= null; - s.DeviceMismatchBehavior = DeviceMismatchBehavior.Allow; // SessionId is the primary opaque token, carried via cookie t.IssueJwt = false; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs index 0eaf5182..f5697aba 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -9,6 +9,6 @@ namespace CodeBeam.UltimateAuth.Server.Options; public interface IEffectiveServerOptionsProvider { UAuthServerOptions GetOriginal(HttpContext context); - EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile); - EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile); + EffectiveUAuthServerOptions GetEffective(HttpContext context, UAuthClientProfile clientProfile); + EffectiveUAuthServerOptions GetEffective(TenantKey tenant, UAuthClientProfile clientProfile); } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs index 192b2786..9ab74357 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -15,8 +15,7 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (session.MaxLifetime.HasValue && session.MaxLifetime <= TimeSpan.Zero) errors.Add("Session.MaxLifetime must be greater than zero when specified."); - if (session.MaxLifetime.HasValue && - session.MaxLifetime < session.Lifetime) + if (session.MaxLifetime.HasValue && session.MaxLifetime < session.Lifetime) errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index 249de06c..e6af7b10 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -59,7 +59,8 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo Tenant = flow.Tenant, SessionId = request.SessionId.Value, Now = now, - Device = request.Device + Device = request.Device, + SessionOptions = flow.Options.Session }, ct); @@ -68,7 +69,7 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + TouchInterval = flow.Options.Session.TouchInterval }; var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); @@ -118,7 +119,8 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re Tenant = flow.Tenant, SessionId = request.SessionId.Value, Now = now, - Device = request.Device + Device = request.Device, + SessionOptions = flow.Options.Session }, ct); @@ -141,7 +143,7 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + TouchInterval = flow.Options.Session.TouchInterval }; var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); @@ -171,7 +173,8 @@ private async Task HandleSemiHybridAsync(AuthFlowContext flow Tenant = flow.Tenant, SessionId = request.SessionId.Value, Now = now, - Device = request.Device + Device = request.Device, + SessionOptions = flow.Options.Session }, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index fbdc931b..4d279ddc 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -41,7 +41,7 @@ public async Task RotateAsync(AuthFlowContext flo if (validation.State == RefreshTokenValidationState.Consumed) { - var concurrencyWindow = flow.OriginalOptions.Token.RefreshTokenConcurrentRequestWindow; + var concurrencyWindow = flow.Options.Token.RefreshTokenConcurrentRequestWindow; if (IsLikelyConcurrentRequest(validation, context.Now, concurrencyWindow)) { @@ -79,7 +79,7 @@ public async Task RotateAsync(AuthFlowContext flo var tokenContext = new TokenIssuanceContext { - Tenant = flow.OriginalOptions.MultiTenant.Enabled + Tenant = flow.Options.MultiTenant.Enabled ? validation.Tenant : TenantKey.Single, diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index fa97d6c1..f37c7a97 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -11,13 +11,11 @@ internal sealed class UAuthSessionValidator : ISessionValidator { private readonly ISessionStoreFactory _storeFactory; private readonly IUserClaimsProvider _claimsProvider; - private readonly UAuthServerOptions _options; - public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) + public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider) { _storeFactory = storeFactory; _claimsProvider = claimsProvider; - _options = options.Value; } // TODO: Improve Device binding @@ -26,6 +24,8 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi // TODO: Add GetSessionAggregate store method to 1 call instead of 3 calls of root chain session public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { + var options = context.SessionOptions ?? throw new InvalidOperationException("Session options are required for local session validation."); + var kernel = _storeFactory.Create(context.Tenant); var session = await kernel.GetSessionAsync(context.SessionId, ct); @@ -40,7 +40,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); - var chainState = chain.GetState(context.Now, _options.Session.IdleTimeout); + var chainState = chain.GetState(context.Now, options.IdleTimeout); if (chainState != SessionState.Active) return SessionValidationResult.Invalid(chainState, chain.UserKey, session.SessionId, chain.ChainId); @@ -67,10 +67,10 @@ public async Task ValidateSessionAsync(SessionValidatio { if (!Equals(chain.Device.DeviceId, context.Device.DeviceId)) { - if (_options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + if (options.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); - //if (_options.Session.DeviceMismatchBehavior == AllowAndRebind) + //if (options.Session.DeviceMismatchBehavior == AllowAndRebind) } } //else diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs index 56003bfb..189c192c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -126,6 +126,4 @@ await service.ChangeSecretAsync(context, newLogin.IsSuccess.Should().BeTrue(); } - - } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index f7bba806..30c2cc97 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -37,8 +37,7 @@ public static AuthFlowContext New( isAuthenticated: isAuthenticated, userKey: userKey ?? (isAuthenticated ? UserKey.New() : null), session: session, - originalOptions: TestServerOptions.Default(), - effectiveOptions: TestServerOptions.Effective(), + options: TestServerOptions.Default(), response: response ?? new EffectiveAuthResponse( sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs index 99e87dbb..0bbaf81d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs @@ -7,6 +7,5 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal sealed class TestAuthModeResolver : IEffectiveAuthModeResolver { - public UAuthMode Resolve(UAuthClientProfile profile, AuthFlowType flowType) - => UAuthMode.PureOpaque; + public UAuthMode Resolve(UAuthClientProfile profile) => UAuthMode.PureOpaque; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs index 948cb560..718b93b5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.ResourceApi; using FluentAssertions; using Microsoft.AspNetCore.Http; @@ -395,12 +396,10 @@ private static SessionValidationContext CreateValidationContext() return new SessionValidationContext { Tenant = Tenant, - SessionId = AuthSessionId.Parse(SessionValue, null), - Now = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero), - - Device = DeviceContext.Anonymous() + Device = DeviceContext.Anonymous(), + SessionOptions = new UAuthSessionOptions() }; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs index 9bbd5e12..cfef81d3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/CookieInfrastructureTests.cs @@ -127,7 +127,7 @@ public void Build_should_resolve_same_site_from_deployment_mode( cookie.SameSite = null; var context = AuthFlowTestFactory.New(); - context.OriginalOptions.HubDeploymentMode = deploymentMode; + context.Options.HubDeploymentMode = deploymentMode; var options = _builder.Build( CreateResponse(GrantKind.Session, cookie), @@ -148,7 +148,7 @@ public void Explicit_same_site_should_override_deployment_mode( cookie.SameSite = explicitMode; var context = AuthFlowTestFactory.New(); - context.OriginalOptions.HubDeploymentMode = + context.Options.HubDeploymentMode = UAuthHubDeploymentMode.External; var options = _builder.Build( @@ -175,7 +175,7 @@ public void Explicit_max_age_should_override_all_other_lifetimes() var context = AuthFlowTestFactory.New(); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromHours(4); var options = _builder.Build( @@ -198,7 +198,7 @@ public void Absolute_lifetime_override_should_take_precedence_over_grant_lifetim var context = AuthFlowTestFactory.New(); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromHours(4); var options = _builder.Build( @@ -251,10 +251,10 @@ public void Pure_opaque_session_should_use_idle_timeout() var context = AuthFlowTestFactory.New( mode: UAuthMode.PureOpaque); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromMinutes(20); - context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + context.Options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); var options = _builder.Build( @@ -273,10 +273,10 @@ public void Hybrid_session_should_use_refresh_lifetime_when_longer() var context = AuthFlowTestFactory.New( mode: UAuthMode.Hybrid); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromMinutes(20); - context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + context.Options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); var options = _builder.Build( @@ -296,10 +296,10 @@ public void Hybrid_session_should_use_idle_timeout_when_longer() var context = AuthFlowTestFactory.New( mode: UAuthMode.Hybrid); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromHours(12); - context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + context.Options.Token.RefreshTokenLifetime = TimeSpan.FromHours(2); var options = _builder.Build( @@ -319,10 +319,10 @@ public void Non_hybrid_session_should_use_idle_timeout() var context = AuthFlowTestFactory.New( mode: UAuthMode.PureJwt); - context.EffectiveOptions.Options.Session.IdleTimeout = + context.Options.Session.IdleTimeout = TimeSpan.FromMinutes(15); - context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + context.Options.Token.RefreshTokenLifetime = TimeSpan.FromDays(1); var options = _builder.Build( @@ -344,7 +344,7 @@ public void Access_token_cookie_should_use_access_token_lifetime() var context = AuthFlowTestFactory.New(); - context.EffectiveOptions.Options.Token.AccessTokenLifetime = + context.Options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(15); var options = _builder.Build( @@ -362,7 +362,7 @@ public void Refresh_token_cookie_should_use_refresh_token_lifetime() var context = AuthFlowTestFactory.New(); - context.EffectiveOptions.Options.Token.RefreshTokenLifetime = + context.Options.Token.RefreshTokenLifetime = TimeSpan.FromDays(14); var options = _builder.Build( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs index 9b72cccf..b5e00f60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -16,7 +16,7 @@ public class EffectiveAuthModeResolverTests [InlineData(UAuthClientProfile.Api, UAuthMode.PureJwt)] public void Default_Mode_Is_Derived_From_ClientProfile(UAuthClientProfile profile, UAuthMode expected) { - var mode = _resolver.Resolve(clientProfile: profile, flowType: AuthFlowType.Login); + var mode = _resolver.Resolve(clientProfile: profile); Assert.Equal(expected, mode); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs index a386347f..ad1b9187 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -21,7 +21,7 @@ public void Original_Options_Are_Not_Mutated() var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); var ctx = TestHttpContext.Create(); - var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective(ctx, UAuthClientProfile.BlazorServer); effective.Options.Token.AccessTokenLifetime = TimeSpan.FromSeconds(10); Assert.NotEqual(baseOptions.Token.AccessTokenLifetime, effective.Options.Token.AccessTokenLifetime); @@ -34,7 +34,7 @@ public void EffectiveMode_Is_Determined_By_ModeResolver() var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); var ctx = TestHttpContext.Create(); - var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.Api); + var effective = provider.GetEffective(ctx, UAuthClientProfile.Api); Assert.Equal(UAuthMode.PureJwt, effective.Mode); } @@ -46,7 +46,7 @@ public void Mode_Defaults_Are_Applied_Before_Overrides() var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); var ctx = TestHttpContext.Create(); - var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective(ctx, UAuthClientProfile.BlazorServer); Assert.True(effective.Options.Session.SlidingExpiration); Assert.NotNull(effective.Options.Session.IdleTimeout); @@ -64,7 +64,7 @@ public void ModeConfiguration_Overrides_Mode_Defaults() var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); var ctx = TestHttpContext.Create(); - var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective(ctx, UAuthClientProfile.BlazorServer); Assert.Equal(TimeSpan.FromMinutes(1), effective.Options.Token.AccessTokenLifetime); } @@ -77,8 +77,8 @@ public void Each_Call_Returns_New_EffectiveOptions_Instance() var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); var ctx = TestHttpContext.Create(); - var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); - var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var first = provider.GetEffective(ctx, UAuthClientProfile.BlazorServer); + var second = provider.GetEffective(ctx, UAuthClientProfile.BlazorServer); Assert.NotSame(first.Options, second.Options); } @@ -95,10 +95,7 @@ public void Effective_options_should_apply_defaults_for_each_auth_mode( var original = new UAuthServerOptions(); var provider = CreateProviderForMode(original, mode); - var effective = provider.GetEffective( - TenantKey.Single, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective(TenantKey.Single, UAuthClientProfile.BlazorServer); effective.Mode.Should().Be(mode); effective.Options.Should().NotBeSameAs(original); @@ -118,10 +115,7 @@ public void SemiHybrid_should_apply_expected_session_defaults() var provider = CreateProviderForMode(original, UAuthMode.SemiHybrid); - var effective = provider.GetEffective( - TenantKey.Single, - AuthFlowType.Login, - UAuthClientProfile.BlazorServer); + var effective = provider.GetEffective(TenantKey.Single, UAuthClientProfile.BlazorServer); effective.Options.Session.SlidingExpiration.Should().BeFalse(); effective.Options.Session.TouchInterval.Should().BeNull(); @@ -150,7 +144,6 @@ public void SemiHybrid_should_apply_expected_token_defaults() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.BlazorServer); var token = effective.Options.Token; @@ -171,7 +164,6 @@ public void SemiHybrid_should_apply_expected_cookie_defaults() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.BlazorServer); effective.Options.Cookie.AccessToken.Lifetime.IdleBuffer @@ -197,14 +189,13 @@ public void PureOpaque_should_initialize_missing_optional_session_defaults() 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); + .Should().Be(DeviceMismatchBehavior.Reject); effective.Options.Token.IssueJwt.Should().BeFalse(); effective.Options.Token.IssueOpaque.Should().BeFalse(); @@ -237,7 +228,6 @@ public void PureOpaque_should_preserve_explicit_optional_session_values() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.BlazorServer); effective.Options.Session.IdleTimeout.Should().Be(TimeSpan.FromHours(4)); @@ -254,7 +244,6 @@ public void Hybrid_should_apply_expected_defaults() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.BlazorServer); var options = effective.Options; @@ -298,7 +287,6 @@ public void PureJwt_should_apply_stateless_defaults() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.Api); var options = effective.Options; @@ -333,7 +321,6 @@ public void Unsupported_auth_mode_should_throw() Action act = () => provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.Api); act.Should() @@ -356,7 +343,6 @@ public void SemiHybrid_mode_configuration_should_override_defaults() var effective = provider.GetEffective( TenantKey.Single, - AuthFlowType.Login, UAuthClientProfile.BlazorServer); effective.Options.Token.AccessTokenLifetime @@ -387,9 +373,7 @@ public FixedAuthModeResolver(UAuthMode mode) _mode = mode; } - public UAuthMode Resolve( - UAuthClientProfile clientProfile, - AuthFlowType flowType) + public UAuthMode Resolve(UAuthClientProfile clientProfile) { return _mode; } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs index 9fda0983..cf6aad14 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs @@ -655,8 +655,7 @@ private static AuthFlowContext CreateFlowWithSession( isAuthenticated: true, userKey: session.UserKey, session: session, - originalOptions: source.OriginalOptions, - effectiveOptions: source.EffectiveOptions, + options: source.Options, response: source.Response, primaryTokenKind: source.PrimaryTokenKind, returnUrlInfo: source.ReturnUrlInfo @@ -676,8 +675,7 @@ private static AuthFlowContext CreateUnauthenticatedFlow() isAuthenticated: false, userKey: null, session: null, - originalOptions: source.OriginalOptions, - effectiveOptions: source.EffectiveOptions, + options: source.Options, response: source.Response, primaryTokenKind: source.PrimaryTokenKind, returnUrlInfo: source.ReturnUrlInfo diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs index 427b8429..ff12d3be 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs @@ -551,7 +551,7 @@ public async Task CompleteAsync_WhenLoginFailsWithoutHub_RedirectsToLogin() .Subject; redirect.Url.Should().Be( - fixture.Flow.OriginalOptions.Hub.LoginPath ?? "/login"); + fixture.Flow.Options.Hub.LoginPath ?? "/login"); fixture.CredentialWriter.VerifyNoOtherCalls(); fixture.RedirectResolver.VerifyNoOtherCalls(); @@ -829,8 +829,7 @@ private static AuthFlowContext CreateNonLoginFlow() isAuthenticated: source.IsAuthenticated, userKey: source.UserKey, session: source.Session, - originalOptions: source.OriginalOptions, - effectiveOptions: source.EffectiveOptions, + options: source.Options, response: source.Response, primaryTokenKind: source.PrimaryTokenKind, returnUrlInfo: source.ReturnUrlInfo); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs index 76c39738..b6cb8033 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -40,7 +40,7 @@ public void LoginFlow_Uses_Configured_Redirect_Options() var optionsProvider = provider.GetRequiredService(); var resolver = provider.GetRequiredService(); - var effective = optionsProvider.GetEffective(TenantKey.Single, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var effective = optionsProvider.GetEffective(TenantKey.Single, UAuthClientProfile.BlazorServer); var response = resolver.Resolve(effective.Mode, AuthFlowType.Login, UAuthClientProfile.BlazorServer, effective); response.Redirect.AllowReturnUrlOverride.Should().BeFalse(); @@ -59,12 +59,12 @@ public async Task ClientProfile_Is_Read_From_Header() } [Theory] - [InlineData(UAuthClientProfile.BlazorWasm, AuthFlowType.Login, UAuthMode.Hybrid)] - [InlineData(UAuthClientProfile.BlazorServer, AuthFlowType.Login, UAuthMode.PureOpaque)] - public void ClientProfile_Resolves_To_Correct_Mode(UAuthClientProfile profile, AuthFlowType flow, UAuthMode expected) + [InlineData(UAuthClientProfile.BlazorWasm, UAuthMode.Hybrid)] + [InlineData(UAuthClientProfile.BlazorServer, UAuthMode.PureOpaque)] + public void ClientProfile_Resolves_To_Correct_Mode(UAuthClientProfile profile, UAuthMode expected) { var resolver = new EffectiveAuthModeResolver(); - var mode = resolver.Resolve(profile, flow); + var mode = resolver.Resolve(profile); mode.Should().Be(expected); } @@ -176,7 +176,7 @@ public void Absolute_ReturnUrl_Outside_AllowedOrigins_Throws() var resolver = TestRedirectResolver.Create(); var ctx = TestHttpContext.Create(); - flow.OriginalOptions.Hub.AllowedClientOrigins.Add("https://app.example.com"); + flow.Options.Hub.AllowedClientOrigins.Add("https://app.example.com"); Action act = () => resolver.ResolveSuccess(flow, ctx); act.Should().Throw().WithMessage("*not allowed*"); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index df376009..ce25294b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -496,8 +496,7 @@ private static AuthFlowContext CreateFlow(UAuthMode mode) isAuthenticated: true, userKey: UserKey.New(), session: null, - originalOptions: original, - effectiveOptions: effective, + options: effective.Options, response: new EffectiveAuthResponse( sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 571095f9..4c96b47c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -690,8 +690,7 @@ private static AuthFlowContext CreateFlow(TenantKey? tenant = null, bool multiTe isAuthenticated: true, userKey: UserKey.New(), session: null, - originalOptions: options, - effectiveOptions: TestServerOptions.Effective(UAuthMode.Hybrid), + options: options, response: new EffectiveAuthResponse( sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs index 2c35608e..fd272709 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs @@ -675,8 +675,7 @@ private static AuthFlowContext CreateUnauthenticatedFlow() isAuthenticated: false, userKey: null, session: null, - originalOptions: authenticated.OriginalOptions, - effectiveOptions: authenticated.EffectiveOptions, + options: authenticated.Options, response: authenticated.Response, primaryTokenKind: authenticated.PrimaryTokenKind, returnUrlInfo: authenticated.ReturnUrlInfo); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs index 6b6c5e1d..1c911f4b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs @@ -746,8 +746,7 @@ private static AuthFlowContext CreateFlow( isAuthenticated: userKey.HasValue, userKey: userKey, session: session, - originalOptions: TestServerOptions.Default(), - effectiveOptions: TestServerOptions.Effective(), + options: TestServerOptions.Default(), response: new EffectiveAuthResponse( sessionIdDelivery: CredentialResponseOptions.Disabled( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs index f3c1f233..e7395f83 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAuthenticationHandlerTests.cs @@ -5,8 +5,10 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -360,6 +362,13 @@ private static TestSetup CreateTestContext( var services = new ServiceCollection(); + var effectiveOptionsProvider = new Mock(); + + var clientProfileReader = new Mock(); + + services.AddSingleton(effectiveOptionsProvider.Object); + services.AddSingleton(clientProfileReader.Object); + services.AddLogging(); services.AddSingleton( @@ -374,10 +383,7 @@ private static TestSetup CreateTestContext( services.AddSingleton( clock.Object); - services - .AddAuthentication( - UAuthConstants.SchemeDefaults.GlobalScheme) - .AddUAuthResourceApi(); + services.AddAuthentication(UAuthConstants.SchemeDefaults.GlobalScheme).AddUAuthResourceApi(); var provider = services.BuildServiceProvider(); @@ -386,9 +392,7 @@ private static TestSetup CreateTestContext( RequestServices = provider }; - http.Items[ - UAuthConstants.HttpItems.TenantContextKey] = - UAuthTenantContext.Resolved(Tenant); + http.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(Tenant); return new TestSetup( http, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs index f67f92b3..2f6a06b0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs @@ -567,15 +567,15 @@ private static Fixture CreateFixture( var sut = new UAuthSessionValidator( storeFactory.Object, - claimsProvider.Object, - Options.Create(options)); + claimsProvider.Object); var context = new SessionValidationContext { Tenant = tenant, SessionId = session.SessionId, Now = Now, - Device = requestDevice ?? TestDevice.Default() + Device = requestDevice ?? TestDevice.Default(), + SessionOptions = options.Session }; return new Fixture( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs index 026ce404..8d89ece3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/UAuthSessionIssuerTests.cs @@ -4,11 +4,12 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +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.Extensions.Options; using Moq; using System.Security; @@ -36,7 +37,7 @@ public sealed class UAuthSessionIssuerTests public async Task IssueSessionAsync_WhenModeIsPureJwt_ShouldRejectSessionIssuance() { var fixture = CreateFixture(); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, mode: UAuthMode.PureJwt); var act = () => @@ -60,7 +61,7 @@ public async Task IssueSessionAsync_WhenOpaqueGeneratorReturnsInvalidId_ShouldFa var fixture = CreateFixture( opaqueToken: "invalid"); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var act = () => fixture.Sut.IssueSessionAsync(context); @@ -81,7 +82,7 @@ await act.Should() public async Task IssueSessionAsync_WhenActiveRootDoesNotExist_ShouldCreateRoot() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); fixture.Store .Setup(x => x.GetActiveRootByUserAsync( @@ -119,7 +120,7 @@ public async Task IssueSessionAsync_WhenActiveRootDoesNotExist_ShouldCreateRoot( public async Task IssueSessionAsync_WhenActiveRootExists_ShouldReuseRoot() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var root = CreateRoot( context.Tenant, @@ -154,7 +155,7 @@ public async Task IssueSessionAsync_WhenActiveRootExists_ShouldReuseRoot() public async Task IssueSessionAsync_WhenChainIdIsNotProvided_ShouldCreateNewChain() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var root = CreateRoot( context.Tenant, @@ -198,7 +199,7 @@ public async Task IssueSessionAsync_WhenChainIdIsNotProvided_ShouldCreateNewChai public async Task IssueSessionAsync_WhenExistingChainIsProvided_ShouldReuseChain() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var root = CreateRoot( context.Tenant, @@ -209,7 +210,7 @@ public async Task IssueSessionAsync_WhenExistingChainIsProvided_ShouldReuseChain context.UserKey, context.Tenant); - context = CreateIssuanceContext( + context = CreateIssuanceContext(fixture, userKey: context.UserKey, chainId: chain.ChainId); @@ -247,7 +248,7 @@ public async Task IssueSessionAsync_WhenExplicitChainDoesNotExist_CurrentlyCreat var requestedChainId = SessionChainId.New(); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, userKey: userKey, chainId: requestedChainId); @@ -310,7 +311,7 @@ public async Task IssueSessionAsync_WhenExistingChainBelongsToDifferentUser_Shou otherUser, Tenant); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, userKey: user, chainId: chain.ChainId); @@ -340,7 +341,7 @@ public async Task IssueSessionAsync_WhenExistingChainBelongsToDifferentTenant_Sh user, otherTenant); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, userKey: user, chainId: chain.ChainId); @@ -370,7 +371,7 @@ public async Task IssueSessionAsync_WhenChainBelongsToDifferentRoot_ShouldReject var chain = CreateChain(otherRoot, user, Tenant); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, userKey: user, chainId: chain.ChainId); @@ -399,7 +400,7 @@ public async Task IssueSessionAsync_WhenChainIsRevoked_ShouldReject() chain = chain.Revoke(Now); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, userKey: user, chainId: chain.ChainId); @@ -423,7 +424,7 @@ public async Task IssueSessionAsync_ShouldUseConfiguredSessionLifetime() var fixture = CreateFixture( lifetime: TimeSpan.FromHours(4)); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); SetupNewIssuance(fixture, context); @@ -441,7 +442,7 @@ public async Task IssueSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapExpiration lifetime: TimeSpan.FromHours(8), maxLifetime: TimeSpan.FromHours(2)); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); SetupNewIssuance(fixture, context); @@ -459,7 +460,7 @@ public async Task IssueSessionAsync_WhenMaxLifetimeIsLonger_ShouldUseNormalLifet lifetime: TimeSpan.FromHours(4), maxLifetime: TimeSpan.FromHours(24)); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); SetupNewIssuance(fixture, context); @@ -478,7 +479,7 @@ public async Task IssueSessionAsync_WhenMaxLifetimeIsLonger_ShouldUseNormalLifet public async Task IssueSessionAsync_ShouldPersistSessionAndAttachItToChain() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var root = CreateRoot( context.Tenant, @@ -537,9 +538,9 @@ public async Task IssueSessionAsync_ShouldPersistSessionAndAttachItToChain() [Fact] public async Task IssueSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMetadataOnly() { - var fixture = CreateFixture(); + var fixture = CreateFixture(mode: UAuthMode.SemiHybrid); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, mode: UAuthMode.SemiHybrid); SetupNewIssuance(fixture, context); @@ -555,7 +556,7 @@ public async Task IssueSessionAsync_WhenModeIsHybrid_ShouldNotMarkResultAsMetada { var fixture = CreateFixture(); - var context = CreateIssuanceContext( + var context = CreateIssuanceContext(fixture, mode: UAuthMode.Hybrid); SetupNewIssuance(fixture, context); @@ -576,7 +577,7 @@ public async Task IssueSessionAsync_WhenSessionLimitReached_ShouldRemoveOldNonAc var fixture = CreateFixture( maxSessionsPerChain: 2); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); var root = CreateRoot( context.Tenant, @@ -606,7 +607,7 @@ public async Task IssueSessionAsync_WhenSessionLimitReached_ShouldRemoveOldNonAc chain.ChainId, Now.AddHours(-4)); - context = CreateIssuanceContext( + context = CreateIssuanceContext(fixture, userKey: context.UserKey, chainId: chain.ChainId); @@ -657,7 +658,7 @@ public async Task IssueSessionAsync_WhenBelowSessionLimit_ShouldNotRemoveSession var fixture = CreateFixture( maxSessionsPerChain: 3); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); SetupNewIssuance(fixture, context); @@ -680,7 +681,7 @@ public async Task RotateSessionAsync_WhenOpaqueGeneratorReturnsInvalidId_ShouldF var fixture = CreateFixture( opaqueToken: "invalid"); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var act = () => fixture.Sut.RotateSessionAsync(context); @@ -707,7 +708,7 @@ await act.Should() public async Task RotateSessionAsync_WhenRootDoesNotExist_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); fixture.Store .Setup(x => x.GetActiveRootByUserAsync( @@ -726,7 +727,7 @@ await act.Should() public async Task RotateSessionAsync_WhenSessionDoesNotExist_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -751,7 +752,7 @@ await act.Should() public async Task RotateSessionAsync_WhenSessionIsRevoked_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -786,7 +787,7 @@ await act.Should() public async Task RotateSessionAsync_WhenSessionIsExpired_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -824,7 +825,7 @@ public async Task RotateSessionAsync_WhenSessionBelongsToDifferentUser_ShouldThr { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var otherUser = UserKey.New(); var root = CreateRoot( @@ -857,7 +858,7 @@ await act.Should() public async Task RotateSessionAsync_WhenSecurityVersionDoesNotMatch_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -889,7 +890,7 @@ await act.Should() public async Task RotateSessionAsync_WhenChainDoesNotExist_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -925,7 +926,7 @@ await act.Should() public async Task RotateSessionAsync_WhenChainIsRevoked_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -960,7 +961,7 @@ await act.Should() public async Task RotateSessionAsync_WhenChainBelongsToDifferentUser_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var root = CreateRoot( context.Tenant, @@ -995,7 +996,7 @@ await act.Should() public async Task RotateSessionAsync_WhenChainBelongsToDifferentRoot_ShouldThrowSecurityException() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var activeRoot = CreateRoot( context.Tenant, @@ -1040,7 +1041,7 @@ await act.Should() public async Task RotateSessionAsync_ShouldCreateNewSessionOnSameChain() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var setup = SetupSuccessfulRotation( @@ -1077,7 +1078,7 @@ public async Task RotateSessionAsync_ShouldCreateNewSessionOnSameChain() public async Task RotateSessionAsync_ShouldRotateChainActiveSession() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var setup = SetupSuccessfulRotation( @@ -1110,7 +1111,7 @@ public async Task RotateSessionAsync_ShouldRotateChainActiveSession() public async Task RotateSessionAsync_ShouldRevokeOldSession() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var setup = SetupSuccessfulRotation( @@ -1146,7 +1147,7 @@ public async Task RotateSessionAsync_ShouldRevokeOldSession() public async Task RotateSessionAsync_ShouldReturnSameBoundSessionThatWasPersisted() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); var setup = SetupSuccessfulRotation( @@ -1176,9 +1177,9 @@ public async Task RotateSessionAsync_ShouldReturnSameBoundSessionThatWasPersiste [Fact] public async Task RotateSessionAsync_WhenModeIsSemiHybrid_ShouldMarkResultAsMetadataOnly() { - var fixture = CreateFixture(); + var fixture = CreateFixture(mode: UAuthMode.SemiHybrid); - var context = CreateRotationContext( + var context = CreateRotationContext(fixture, mode: UAuthMode.SemiHybrid); SetupSuccessfulRotation( @@ -1198,7 +1199,7 @@ public async Task RotateSessionAsync_WhenMaxLifetimeIsShorter_ShouldCapNewSessio lifetime: TimeSpan.FromHours(8), maxLifetime: TimeSpan.FromHours(2)); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); SetupSuccessfulRotation( fixture, @@ -1575,7 +1576,7 @@ await fixture.Sut.LogoutChainAsync( public async Task IssueSessionAsync_ShouldPassCancellationTokenToTransaction() { var fixture = CreateFixture(); - var context = CreateIssuanceContext(); + var context = CreateIssuanceContext(fixture); SetupNewIssuance(fixture, context); @@ -1597,7 +1598,7 @@ await fixture.Sut.IssueSessionAsync( public async Task RotateSessionAsync_ShouldPassCancellationTokenToTransaction() { var fixture = CreateFixture(); - var context = CreateRotationContext(); + var context = CreateRotationContext(fixture); SetupSuccessfulRotation( fixture, @@ -1625,7 +1626,8 @@ private static Fixture CreateFixture( string opaqueToken = OpaqueToken, TimeSpan? lifetime = null, TimeSpan? maxLifetime = null, - int maxSessionsPerChain = 5) + int maxSessionsPerChain = 5, + UAuthMode mode = UAuthMode.Hybrid) { var storeFactory = new Mock( @@ -1639,6 +1641,8 @@ private static Fixture CreateFixture( new Mock( MockBehavior.Strict); + var flowAccessor = new Mock(MockBehavior.Strict); + tokenGenerator .Setup(x => x.Generate()) .Returns(opaqueToken); @@ -1669,8 +1673,7 @@ private static Fixture CreateFixture( .Returns>, CancellationToken>( (action, ct) => action(ct)); - var options = - new UAuthServerOptions(); + var options = new UAuthServerOptions(); options.Session.Lifetime = lifetime ?? Lifetime; @@ -1684,14 +1687,14 @@ private static Fixture CreateFixture( var sut = new UAuthSessionIssuer( storeFactory.Object, - tokenGenerator.Object, - Options.Create(options)); + tokenGenerator.Object); return new Fixture( sut, storeFactory, store, - tokenGenerator); + tokenGenerator, + options.Session); } // ===================================================================== @@ -1861,10 +1864,7 @@ private static void SetupSessionAndChainPersistence( // Domain helpers // ===================================================================== - private static SessionIssuanceContext CreateIssuanceContext( - UserKey? userKey = null, - SessionChainId? chainId = null, - UAuthMode mode = UAuthMode.Hybrid) + private static SessionIssuanceContext CreateIssuanceContext(Fixture fixture, UserKey? userKey = null, SessionChainId? chainId = null, UAuthMode mode = UAuthMode.Hybrid) { return new SessionIssuanceContext { @@ -1875,26 +1875,26 @@ private static SessionIssuanceContext CreateIssuanceContext( Claims = ClaimsSnapshot.Empty, Metadata = SessionMetadata.Empty, Mode = mode, - ChainId = chainId + ChainId = chainId, + SessionOptions = fixture.SessionOptions }; } - private static SessionRotationContext CreateRotationContext( - UAuthMode mode = UAuthMode.Hybrid) + private static SessionRotationContext CreateRotationContext(Fixture fixture, UAuthMode mode = UAuthMode.Hybrid) { return new SessionRotationContext { Tenant = Tenant, - CurrentSessionId = - AuthSessionId.Parse( - "current-session-000000000000000000000000001", - null), + CurrentSessionId = AuthSessionId.Parse( + "current-session-000000000000000000000000001", + null), UserKey = UserKey.New(), Now = Now, Device = TestDevice.Default(), Claims = ClaimsSnapshot.Empty, Metadata = SessionMetadata.Empty, - Mode = mode + Mode = mode, + SessionOptions = fixture.SessionOptions }; } @@ -1954,5 +1954,6 @@ private sealed record Fixture( UAuthSessionIssuer Sut, Mock StoreFactory, Mock Store, - Mock TokenGenerator); + Mock TokenGenerator, + UAuthSessionOptions SessionOptions); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs index 0aa2f3ae..6b2ff195 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -549,7 +549,7 @@ await fixture.Sut.IssueRefreshTokenAsync( captured.ExpiresAt.Should() .Be(Now.Add( - flow.OriginalOptions.Token.RefreshTokenLifetime)); + flow.Options.Token.RefreshTokenLifetime)); fixture.StoreFactory.Verify( x => x.Create(flow.Tenant), @@ -1068,6 +1068,39 @@ public async Task IssueAccessTokenAsync_WhenJwtIdDisabled_ShouldRejectCustomJti( Times.Never); } + [Fact] + public async Task IssueTokens_ShouldUseEffectiveLifetimes() + { + var fixture = CreateFixture(); + var flow = AuthFlowTestFactory.New( + mode: UAuthMode.Hybrid, + tenant: Tenant); + + flow.Options.Token.AccessTokenLifetime = + TimeSpan.FromMinutes(15); + flow.Options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + + flow.Options.Token.AccessTokenLifetime = + TimeSpan.FromMinutes(5); + flow.Options.Token.RefreshTokenLifetime = + TimeSpan.FromMinutes(30); + + var context = CreateTokenContext( + sessionId: CreateSessionId( + "effective-session-0000000000000000000001")); + + var access = await fixture.Sut.IssueAccessTokenAsync( + flow, context); + + var refresh = await fixture.Sut.IssueRefreshTokenAsync( + flow, context, RefreshTokenPersistence.DoNotPersist); + + access.ExpiresAt.Should().Be(Now.AddMinutes(5)); + refresh.Should().NotBeNull(); + refresh!.ExpiresAt.Should().Be(Now.AddMinutes(30)); + } + // ===================================================================== // Context helpers // ===================================================================== @@ -1095,20 +1128,20 @@ private static AuthFlowContext CreateFlow( mode: mode, tenant: Tenant); - flow.OriginalOptions.Token.AccessTokenLifetime = + flow.Options.Token.AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15); - flow.OriginalOptions.Token.RefreshTokenLifetime = + flow.Options.Token.RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30); if (issuer is not null) - flow.OriginalOptions.Token.Issuer = issuer; + flow.Options.Token.Issuer = issuer; if (audience is not null) - flow.OriginalOptions.Token.Audience = audience; + flow.Options.Token.Audience = audience; - flow.OriginalOptions.Token.KeyId = keyId; - flow.OriginalOptions.Token.AddJwtIdClaim = addJwtIdClaim; + flow.Options.Token.KeyId = keyId; + flow.Options.Token.AddJwtIdClaim = addJwtIdClaim; return flow; } From 036867e7fb6f43c3de510e0ebfe1f6b7b0f01e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:28:46 +0300 Subject: [PATCH 12/20] Fix Sliding Expiration on Refresh Flow (#70) --- .../Program.cs | 9 +- .../Program.cs | 16 +- .../Program.cs | 2 + .../Domain/Session/UAuthSession.cs | 21 + .../Auth/Context/AuthFlowContextFactory.cs | 22 +- .../Flows/Refresh/SessionTouchPolicy.cs | 3 + .../Flows/Refresh/SessionTouchService.cs | 65 +- .../Issuers/UAuthSessionIssuer.cs | 18 +- .../Options/Defaults/ConfigureDefaults.cs | 5 +- .../Validators/UAuthServerOptionsValidator.cs | 15 +- .../Services/RefreshFlowService.cs | 10 +- .../Services/UAuthSessionValidator.cs | 2 - .../RefreshTests.cs | 49 +- .../SessionSlidingExpirationTests.cs | 562 ++++++++++++++++++ .../Server/ServerOptionsValidatorTests.cs | 10 - .../Server/SessionTouchServiceTests.cs | 340 ++++++++--- 16 files changed, 987 insertions(+), 162 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs index 54e3346e..814fd843 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -29,16 +29,17 @@ builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshDetails = true; + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.Behavior.AllowMultipleUsernames = true; + o.UserProfile.EnableMultiProfile = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.Lifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); - o.Login.MaxFailedAttempts = 2; - o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.Behavior.AllowMultipleUsernames = true; - o.UserProfile.EnableMultiProfile = true; }) .AddUltimateAuthInMemory() .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 6acf2d76..6613d3f2 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -43,21 +43,29 @@ builder.Services.AddUltimateAuthServer(o => { o.Diagnostics.EnableRefreshDetails = true; + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.Behavior.AllowMultipleUsernames = true; + + // No-op and touch with max lifetime example //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); //o.Session.Lifetime = TimeSpan.FromSeconds(32); //o.Session.TouchInterval = TimeSpan.FromSeconds(9); //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); - o.Login.MaxFailedAttempts = 2; - o.Login.LockoutDuration = TimeSpan.FromSeconds(10); - o.Identifiers.Behavior.AllowMultipleUsernames = true; + + // Infinite sliding expiration example + //o.Session.MaxLifetime = null; + //o.Session.Lifetime = TimeSpan.FromSeconds(20); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(10); }) .AddUltimateAuthInMemory(); builder.Services.AddUltimateAuthClientBlazor(o => { - //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; }); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs index a8841e99..90c603f5 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -27,6 +27,8 @@ o.Reauth.Behavior = ReauthBehavior.RaiseEvent; o.Login.AllowCredentialPost = true; o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path + + o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); }); builder.Services.AddScoped(); diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs index b9351524..ba3ef2c7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -139,6 +139,27 @@ public SessionState GetState(DateTimeOffset at) return SessionState.Active; } + public UAuthSession ExtendExpiration(DateTimeOffset expiresAt) + { + if (expiresAt <= ExpiresAt) + return this; + + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + expiresAt, + RevokedAt, + SecurityVersionAtCreation, + Device, + Claims, + Metadata, + Version + 1 + ); + } + public UAuthSession WithChain(SessionChainId chainId) { if (!ChainId.IsUnassigned) diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 94069813..1d921461 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.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.Infrastructure; @@ -69,6 +70,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); SessionSecurityContext? sessionSecurityContext = null; + var isAuthenticated = user?.IsAuthenticated ?? false; + var userKey = user?.UserId; if (!sessionCtx.IsAnonymous) { @@ -84,6 +87,19 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp ct); sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); + + if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid) + { + isAuthenticated = validation.IsValid; + + if (!isAuthenticated) + userKey = null; + } + } + else if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid) + { + isAuthenticated = false; + userKey = null; } if (!tenant.IsValid || tenant.IsUnresolved) @@ -98,8 +114,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp effectiveMode, deviceContext, tenant, - user?.IsAuthenticated ?? false, - user?.UserId, + isAuthenticated, + userKey, sessionSecurityContext, options, response, diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs index e72e8ad3..a5f76e53 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs @@ -3,4 +3,7 @@ public sealed class SessionTouchPolicy { public TimeSpan? TouchInterval { get; init; } + public TimeSpan Lifetime { get; init; } + public bool SlidingExpiration { get; init; } + public TimeSpan? IdleTimeout { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs index 2b29016e..96dae0a3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -14,35 +15,73 @@ public SessionTouchService(ISessionStoreFactory kernelFactory) // It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh. // That's why the service access store direcly: There is no security flow here, only validate and touch session. - public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) + public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, + DateTimeOffset now, CancellationToken ct = default) { - if (!validation.IsValid || validation.ChainId is null) + if (!validation.IsValid || validation.SessionId is null || validation.ChainId is null) + { return SessionRefreshResult.ReauthRequired(); - - if (!policy.TouchInterval.HasValue) - return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); + } var kernel = _kernelFactory.Create(validation.Tenant); + bool isValid = false; bool didTouch = false; - await kernel.ExecuteAsync(async _ => + await kernel.ExecuteAsync(async innerCt => { - var chain = await kernel.GetChainAsync(validation.ChainId.Value); + var chain = await kernel.GetChainAsync(validation.ChainId.Value, innerCt); + var session = await kernel.GetSessionAsync(validation.SessionId.Value, innerCt); + + if (chain is null || session is null) + return; - if (chain is null || chain.IsRevoked) + if (chain.GetState(now, policy.IdleTimeout) != SessionState.Active || session.GetState(now) != SessionState.Active) return; - if (now - chain.LastSeenAt < policy.TouchInterval.Value) + if (chain.ActiveSessionId != session.SessionId || + session.ChainId != chain.ChainId || + session.Tenant != validation.Tenant || + chain.Tenant != validation.Tenant || + session.UserKey != chain.UserKey) return; - var expectedVersion = chain.Version; - var touched = chain.Touch(now); + if (validation.UserKey != session.UserKey || validation.ChainId != session.ChainId) + return; + + isValid = true; + + bool shouldTouch = sessionTouchMode == SessionTouchMode.Force || !policy.TouchInterval.HasValue || now - chain.LastSeenAt >= policy.TouchInterval.Value; + + if (!shouldTouch) + return; + + var touchedChain = chain.Touch(now); + + if (policy.SlidingExpiration) + { + var proposedExpiry = now.Add(policy.Lifetime); + + var newExpiry = chain.AbsoluteExpiresAt is { } absolute + && proposedExpiry > absolute + ? absolute + : proposedExpiry; + + var extendedSession = session.ExtendExpiration(newExpiry); + + if (!ReferenceEquals(extendedSession, session)) + { + await kernel.SaveSessionAsync(extendedSession, session.Version, innerCt); + } + } + + await kernel.SaveChainAsync(touchedChain, chain.Version, innerCt); - await kernel.SaveChainAsync(touched, expectedVersion); didTouch = true; }, ct); - return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); + return isValid + ? SessionRefreshResult.Success(validation.SessionId.Value, didTouch) + : SessionRefreshResult.ReauthRequired(); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index e2ac81a0..06d2c99d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -33,13 +33,15 @@ public async Task IssueSessionAsync(SessionIssuanceContext contex if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId)) throw new InvalidCastException("Can't create opaque id."); - var expiresAt = now.Add(sessionOptions.Lifetime); + var absoluteExpiresAt = sessionOptions.MaxLifetime is { } max + ? now.Add(max) + : (DateTimeOffset?)null; - if (sessionOptions.MaxLifetime is not null) + var sessionExpiresAt = now.Add(sessionOptions.Lifetime); + + if (absoluteExpiresAt is { } absolute && sessionExpiresAt > absolute) { - var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value); - if (absoluteExpiry < expiresAt) - expiresAt = absoluteExpiry; + sessionExpiresAt = absolute; } var kernel = _storeFactory.Create(context.Tenant); @@ -76,7 +78,7 @@ await kernel.ExecuteAsync(async _ => context.Tenant, context.UserKey, now, - expiresAt, + absoluteExpiresAt, context.Device, ClaimsSnapshot.Empty, root.SecurityVersion @@ -110,7 +112,7 @@ await kernel.ExecuteAsync(async _ => context.Tenant, context.UserKey, now, - expiresAt, + absoluteExpiresAt, context.Device, ClaimsSnapshot.Empty, root.SecurityVersion @@ -141,7 +143,7 @@ await kernel.ExecuteAsync(async _ => userKey: context.UserKey, chainId: chain.ChainId, now: now, - expiresAt: expiresAt, + expiresAt: sessionExpiresAt, securityVersion: root.SecurityVersion, device: context.Device, claims: context.Claims, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs index abb1a703..b7bca0c9 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -38,8 +38,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) var c = o.Cookie; var r = o.AuthResponse; - // Session behavior - s.SlidingExpiration = true; + //s.SlidingExpiration = true; // Default value. // Default: long-lived idle session (UX friendly) s.IdleTimeout ??= TimeSpan.FromDays(7); @@ -75,7 +74,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o) var c = o.Cookie; var r = o.AuthResponse; - s.SlidingExpiration = true; + //s.SlidingExpiration = true; // Default value. s.TouchInterval = null; t.IssueJwt = true; diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs index 5b1fe8bc..e04e77ad 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -43,30 +43,21 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) { if (options.Session.Lifetime <= TimeSpan.Zero) { - return ValidateOptionsResult.Fail( - "Session.Lifetime must be greater than zero."); + return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero."); } - if (options.Session.MaxLifetime is not null && - options.Session.MaxLifetime <= TimeSpan.Zero) + if (options.Session.MaxLifetime is not null && options.Session.MaxLifetime <= TimeSpan.Zero) { - return ValidateOptionsResult.Fail( - "Session.MaxLifetime must be greater than zero when specified."); + return ValidateOptionsResult.Fail("Session.MaxLifetime must be greater than zero when specified."); } } - // Only add cross-option validation beyond this point, individual options should validate in their own validators. if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime) { return ValidateOptionsResult.Fail("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime."); } - if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime) - { - return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime."); - } - return ValidateOptionsResult.Success; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index e6af7b10..1e6f0d1f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -69,7 +69,10 @@ private async Task HandleSessionOnlyAsync(AuthFlowContext flo var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.Options.Session.TouchInterval + TouchInterval = flow.Options.Session.TouchInterval, + Lifetime = flow.Options.Session.Lifetime, + SlidingExpiration = flow.Options.Session.SlidingExpiration, + IdleTimeout = flow.Options.Session.IdleTimeout }; var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); @@ -143,7 +146,10 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re var touchPolicy = new SessionTouchPolicy { - TouchInterval = flow.Options.Session.TouchInterval + TouchInterval = flow.Options.Session.TouchInterval, + Lifetime = flow.Options.Session.Lifetime, + SlidingExpiration = flow.Options.Session.SlidingExpiration, + IdleTimeout = flow.Options.Session.IdleTimeout }; var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index f37c7a97..c46cceaa 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -2,8 +2,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; -using CodeBeam.UltimateAuth.Server.Options; -using Microsoft.Extensions.Options; namespace CodeBeam.UltimateAuth.Server.Services; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index 1a356583..a7b343ba 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -121,8 +121,7 @@ public async Task Refresh_PureOpaque_AfterTouch_ShouldNotTouchAgainBeforeNextInt var initial = await GetCurrentChainAsync(_client); - _factory.Clock.Advance( - TimeSpan.FromHours(1)); + _factory.Clock.Advance(TimeSpan.FromMinutes(6)); var first = await RefreshAsync(); @@ -1601,11 +1600,9 @@ public async Task Refresh_Hybrid_WhenSessionExpired_ShouldReturnUnauthorized() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromMinutes(30); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1625,9 +1622,7 @@ await LoginAsync( user, "BlazorWasm"); - // // Session expired, refresh token is still valid. - // factory.Clock.Advance( TimeSpan.FromMinutes(31)); @@ -1650,14 +1645,10 @@ public async Task Refresh_Hybrid_WhenChainIdleTimeoutExceeded_ShouldReturnUnauth { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1699,14 +1690,10 @@ public async Task Refresh_Hybrid_JustBeforeChainIdleTimeout_ShouldSucceed() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); @@ -1749,14 +1736,10 @@ public async Task Refresh_Hybrid_AtChainIdleTimeout_ShouldReturnUnauthorized() { await using var factory = AuthServerFactory.Create(options => { - options.Session.Lifetime = - TimeSpan.FromDays(7); - - options.Session.IdleTimeout = - TimeSpan.FromMinutes(30); - - options.Token.RefreshTokenLifetime = - TimeSpan.FromDays(7); + options.Session.Lifetime = TimeSpan.FromDays(7); + options.Session.MaxLifetime = TimeSpan.FromDays(7); + options.Session.IdleTimeout = TimeSpan.FromMinutes(30); + options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); }); factory.Clock.Reset(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs new file mode 100644 index 00000000..0ecd1bdd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs @@ -0,0 +1,562 @@ +using System.Net; +using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class SessionSlidingExpirationTests +{ + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_RegularRefresh_ShouldSurviveOriginalLifetime(string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (initialSession, initialChain) = await ReadCurrent(factory, client); + var previousExpiry = initialSession.ExpiresAt; + for (int i = 0; i < 3; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var response = await Refresh(client, profile); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (session, chain) = await ReadCurrent(factory, client); + chain.ChainId.Should().Be(initialChain.ChainId); + + session.ExpiresAt.Should().BeAfter(previousExpiry); + previousExpiry = session.ExpiresAt; + } + factory.Clock.UtcNow.Should().BeAfter(initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task SlidingDisabled_RefreshShouldNotExtendExpiration(string profile) + { + await using var factory = CreateFactory(sliding: false); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (initial, _) = await ReadCurrent(factory, client); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using (var response = await Refresh(client, profile)) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (after, _) = await ReadCurrent(factory, client); + after.ExpiresAt.Should().Be(initial.ExpiresAt); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var expired = await Refresh(client, profile); + expired.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task MaxLifetime_RefreshMustNotExtendAbsoluteChainExpiry(string profile) + { + await using var factory = CreateFactory(sliding: true, maxLifetime: TimeSpan.FromDays(9)); + using var client = CreateClient(factory, profile); + await Login(factory, client); + var (_, initialChain) = await ReadCurrent(factory, client); + var absolute = initialChain.AbsoluteExpiresAt; + absolute.Should().Be(factory.Clock.UtcNow.AddDays(9)); + for (int i = 0; i < 4; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + using var response = await Refresh(client, profile); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (session, chain) = await ReadCurrent(factory, client); + chain.AbsoluteExpiresAt.Should().Be(absolute); + session.ExpiresAt.Should().BeOnOrBefore(absolute!.Value); + } + factory.Clock.Advance(TimeSpan.FromDays(1)); + using var boundary = await Refresh(client, profile); + boundary.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PureOpaque_NoOpBeforeInterval_ThenTouchMustExtendSession() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorServer"); + await Login(factory, client); + var (initial, chainBefore) = await ReadCurrent(factory, client); + factory.Clock.Advance(TimeSpan.FromMinutes(1)); + using (var response = await Refresh(client, "BlazorServer")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (noOp, chainNoOp) = await ReadCurrent(factory, client); + noOp.ExpiresAt.Should().Be(initial.ExpiresAt); + chainNoOp.TouchCount.Should().Be(chainBefore.TouchCount); + factory.Clock.Advance(TimeSpan.FromDays(1)); + using (var response = await Refresh(client, "BlazorServer")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + var (touched, chainTouched) = await ReadCurrent(factory, client); + chainTouched.TouchCount.Should().BeGreaterThan(chainBefore.TouchCount); + touched.ExpiresAt.Should().BeAfter(initial.ExpiresAt); + } + + [Fact] + public async Task Hybrid_RotatedToken_ShouldNotLeaveSessionExpiryBehind() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorWasm"); + await Login(factory, client); + var (initial, chainBefore) = await ReadCurrent(factory, client); + var oldRefreshCookie = Cookie(client, "uar"); + factory.Clock.Advance(TimeSpan.FromDays(2)); + using (var response = await Refresh(client, "BlazorWasm")) + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cookie(client, "uar").Should().NotBe(oldRefreshCookie); + var (after, chainAfter) = await ReadCurrent(factory, client); + chainAfter.ChainId.Should().Be(chainBefore.ChainId); + after.ExpiresAt.Should().BeAfter(initial.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task IdentifierGet_ExpiredIdleSession_ShouldReturnUnauthorized(string profile) + { + using var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(5); + o.Session.MaxLifetime = null; + o.Session.IdleTimeout = TimeSpan.FromSeconds(10); + o.Session.TouchInterval = TimeSpan.FromSeconds(5); + }); + + var user = await factory.CreateLoginUserAsync(); + + using var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add( + "X-UDID", $"idle-regression-{Guid.NewGuid():N}"); + client.DefaultRequestHeaders.Add( + "X-UAuth-ClientProfile", profile); + + using var login = await client.PostAsJsonAsync( + "/auth/login", + new { identifier = user.Identifier, secret = user.Secret }); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + login.Headers.TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + client.DefaultRequestHeaders.Add( + "Cookie", + string.Join("; ", cookies!.Select(x => x.Split(';')[0]))); + + // Same session must be accepted before IdleTimeout. + using var before = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + + before.StatusCode.Should().Be(HttpStatusCode.OK); + + // Expire idle time, not the session's normal lifetime. + factory.Clock.Advance(TimeSpan.FromSeconds(11)); + + // Keep the original cookie deliberately. + using var after = await client.PostAsJsonAsync( + "/auth/me/identifiers/get", new { }); + + after.StatusCode.Should().Be(HttpStatusCode.Unauthorized, + "an expired session must not remain authenticated " + + "merely because its cookie is still present"); + } + + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task InfiniteSliding_ThirtyConsecutiveRefreshes_ShouldKeepSessionAlive( + string profile) + { + await using var factory = CreateFactory( + sliding: true, + maxLifetime: null); + + using var client = CreateClient(factory, profile); + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + initialChain.AbsoluteExpiresAt.Should().BeNull(); + + var previousExpiry = initialSession.ExpiresAt; + var previousTouchCount = initialChain.TouchCount; + + for (int i = 0; i < 30; i++) + { + factory.Clock.Advance(TimeSpan.FromDays(2)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"refresh #{i + 1} should succeed"); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.AbsoluteExpiresAt.Should().BeNull(); + + session.ExpiresAt.Should().BeAfter( + previousExpiry, + $"refresh #{i + 1} must extend expiration"); + + session.ExpiresAt.Should().Be( + factory.Clock.UtcNow.AddDays(4)); + + chain.TouchCount.Should().Be( + previousTouchCount + 1); + + previousExpiry = session.ExpiresAt; + previousTouchCount = chain.TouchCount; + } + + factory.Clock.UtcNow.Should().BeAfter( + initialSession.ExpiresAt); + + // 30 refreshes, each separated by 2 days. + factory.Clock.UtcNow.Should().Be( + initialChain.CreatedAt.AddDays(60)); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_JustBeforeSessionExpiry_ShouldExtendSession( + string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + var remaining = + initialSession.ExpiresAt + - factory.Clock.UtcNow + - TimeSpan.FromSeconds(1); + + remaining.Should().BePositive(); + + factory.Clock.Advance(remaining); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + session.ExpiresAt.Should().BeAfter(initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_ExactlyAtSessionExpiry_ShouldRejectRefresh( + string profile) + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, _) = + await ReadCurrent(factory, client); + + factory.Clock.Advance( + initialSession.ExpiresAt - factory.Clock.UtcNow); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_WithIdleTimeout_RegularRefreshes_ShouldRemainActive( + string profile) + { + await using var factory = CreateIdleSlidingFactory(); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + var previousExpiry = initialSession.ExpiresAt; + + for (int i = 0; i < 12; i++) + { + // IdleTimeout = 30 minutes. + // Refresh every 20 minutes. + factory.Clock.Advance(TimeSpan.FromMinutes(20)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be( + HttpStatusCode.NoContent, + $"refresh #{i + 1} occurs before idle timeout"); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.LastSeenAt.Should().Be(factory.Clock.UtcNow); + session.ExpiresAt.Should().BeAfter(previousExpiry); + + previousExpiry = session.ExpiresAt; + } + + // 12 * 20 minutes = 4 hours. + // Original session lifetime = 1 hour. + factory.Clock.UtcNow.Should().BeAfter( + initialSession.ExpiresAt); + } + + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task Sliding_WithIdleTimeout_ExactlyAtBoundary_ShouldRejectRefresh( + string profile) + { + await using var factory = CreateIdleSlidingFactory(); + using var client = CreateClient(factory, profile); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + factory.Clock.Advance(TimeSpan.FromMinutes(30)); + + using var response = await Refresh(client, profile); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var (session, chain) = + await ReadCurrentDirectly(factory, initialChain); + + chain.TouchCount.Should().Be(initialChain.TouchCount); + chain.LastSeenAt.Should().Be(initialChain.LastSeenAt); + session.ExpiresAt.Should().Be(initialSession.ExpiresAt); + } + + [Fact] + public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph() + { + await using var factory = CreateFactory(sliding: true); + using var client = CreateClient(factory, "BlazorWasm"); + + await Login(factory, client); + + var (initialSession, initialChain) = + await ReadCurrent(factory, client); + + // Both requests use the same original refresh cookie. + // Do not apply response cookies until both requests finish. + factory.Clock.Advance(TimeSpan.FromDays(2)); + + using var firstClient = CloneClientWithCookies( + factory, client, "BlazorWasm"); + + using var secondClient = CloneClientWithCookies( + factory, client, "BlazorWasm"); + + using var first = await firstClient.PostAsync( + "/auth/refresh", null); + + using var second = await secondClient.PostAsync( + "/auth/refresh", null); + + // A rotated refresh credential must not be reusable. + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + second.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Apply the successful rotation to the original client. + ApplyCookies(client, first); + + var (session, chain) = + await ReadCurrent(factory, client); + + chain.ChainId.Should().Be(initialChain.ChainId); + chain.ActiveSessionId.Should().NotBeNull(); + session.ChainId.Should().Be(chain.ChainId); + session.ExpiresAt.Should().BeAfter(initialSession.ExpiresAt); + + // The successful rotated credential remains usable. + using var next = await Refresh(client, "BlazorWasm"); + + next.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + + private static AuthServerFactory CreateFactory(bool sliding, TimeSpan? maxLifetime = null) + { + var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromDays(4); + o.Session.SlidingExpiration = sliding; + o.Session.MaxLifetime = maxLifetime; + o.Session.IdleTimeout = null; + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(12); + }); + factory.Clock.Reset(); + return factory; + } + + private static HttpClient CreateClient(AuthServerFactory factory, string profile) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", $"sliding-device-{Guid.NewGuid():N}"); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); + return client; + } + + private static async Task Login(AuthServerFactory factory, HttpClient client) + { + var user = await factory.CreateLoginUserAsync(); + using var response = await client.PostAsJsonAsync("/auth/login", + new { identifier = user.Identifier, secret = user.Secret }); + response.StatusCode.Should().Be(HttpStatusCode.Found); + ApplyCookies(client, response); + } + + private static async Task Refresh(HttpClient client, string profile) + { + var response = await client.PostAsync("/auth/refresh", null); + if (response.IsSuccessStatusCode && profile == "BlazorWasm") + ApplyCookies(client, response); + return response; + } + + private static void ApplyCookies(HttpClient client, HttpResponseMessage response) + { + var cookies = new Dictionary(StringComparer.Ordinal); + if (client.DefaultRequestHeaders.TryGetValues("Cookie", out var old)) + foreach (var item in string.Join("; ", old).Split("; ", StringSplitOptions.RemoveEmptyEntries)) + { + var pair = item.Split('=', 2); + if (pair.Length == 2) cookies[pair[0]] = pair[1]; + } + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + foreach (var item in values) + { + var pair = item.Split(';', 2)[0].Split('=', 2); + if (pair.Length == 2) cookies[pair[0]] = pair[1]; + } + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies.Select(x => $"{x.Key}={x.Value}"))); + } + + private static string Cookie(HttpClient client, string name) => + client.DefaultRequestHeaders.GetValues("Cookie").Single() + .Split("; ", StringSplitOptions.RemoveEmptyEntries) + .Single(x => x.StartsWith(name + "=", StringComparison.Ordinal)); + + private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrent( + AuthServerFactory factory, HttpClient client) + { + using var response = await client.PostAsJsonAsync("/auth/me/sessions/chains", + new PageRequest { PageNumber = 1, PageSize = 50 }); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var page = await response.Content.ReadFromJsonAsync>(); + var chainId = page!.Items.Single(x => x.IsCurrentDevice).ChainId; + using var scope = factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKeys.Single); + var chain = await store.GetChainAsync(chainId); + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().NotBeNull(); + var session = await store.GetSessionAsync(chain.ActiveSessionId!.Value); + session.Should().NotBeNull(); + return (session!, chain); + } + + + private static AuthServerFactory CreateIdleSlidingFactory() + { + var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromHours(1); + o.Session.MaxLifetime = null; + o.Session.SlidingExpiration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(30); + o.Session.TouchInterval = TimeSpan.FromMinutes(5); + + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); + }); + + factory.Clock.Reset(); + return factory; + } + + private static async Task<(UAuthSession Session, UAuthSessionChain Chain)> ReadCurrentDirectly(AuthServerFactory factory, UAuthSessionChain originalChain) + { + using var scope = factory.Services.CreateScope(); + + var store = scope.ServiceProvider + .GetRequiredService() + .Create(TenantKeys.Single); + + var chain = await store.GetChainAsync(originalChain.ChainId); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().NotBeNull(); + + var session = await store.GetSessionAsync( + chain.ActiveSessionId!.Value); + + session.Should().NotBeNull(); + + return (session!, chain); + } + + private static HttpClient CloneClientWithCookies(AuthServerFactory factory, HttpClient source, string profile) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + foreach (var header in source.DefaultRequestHeaders) + { + client.DefaultRequestHeaders.TryAddWithoutValidation( + header.Key, + header.Value); + } + + return client; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 340a615b..7296dd99 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -877,16 +877,6 @@ public void Server_access_token_exceeding_session_max_fails() => 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 => diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs index df10514a..13682b85 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs @@ -20,17 +20,19 @@ public async Task RefreshAsync_WhenValidationIsInvalid_ReturnsReauthRequired() { var factory = new Mock(MockBehavior.Strict); var sut = new SessionTouchService(factory.Object); - var validation = SessionValidationResult.Invalid(SessionState.Revoked); var result = await sut.RefreshAsync( - validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionValidationResult.Invalid(SessionState.Revoked), + Policy(), SessionTouchMode.IfNeeded, Now); - result.RequiresReauth.Should().BeTrue(); result.IsSuccess.Should().BeFalse(); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); + result.RequiresReauth.Should().BeTrue(); + + factory.Verify( + x => x.Create(It.IsAny()), + Times.Never); } [Fact] @@ -38,6 +40,7 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() { var factory = new Mock(MockBehavior.Strict); var sut = new SessionTouchService(factory.Object); + var validation = SessionValidationResult.Invalid( SessionState.Active, userKey: TestUsers.User, @@ -45,62 +48,66 @@ public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.RequiresReauth.Should().BeTrue(); result.IsSuccess.Should().BeFalse(); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); + result.RequiresReauth.Should().BeTrue(); + + factory.Verify( + x => x.Create(It.IsAny()), + Times.Never); } [Fact] - public async Task RefreshAsync_WhenTouchIntervalIsDisabled_ReturnsSuccessWithoutTouchingStore() + public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsReauthRequired() { - var factory = new Mock(MockBehavior.Strict); - var sut = new SessionTouchService(factory.Object); + var factory = new InMemorySessionStoreFactory(); var validation = CreateActiveValidation(); + var sut = new SessionTouchService(factory); var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = null }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - result.SessionId.Should().Be(validation.SessionId); - factory.Verify(x => x.Create(It.IsAny()), Times.Never); } [Fact] - public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenSessionDoesNotExist_ReturnsReauthRequired() { var factory = new InMemorySessionStoreFactory(); - var sut = new SessionTouchService(factory); var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + + var chain = CreateChain(validation, Now.AddMinutes(-2)); + await store.CreateChainAsync(chain); + + var sut = new SessionTouchService(factory); var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - result.SessionId.Should().Be(validation.SessionId); } [Fact] - public async Task RefreshAsync_WhenChainIsRevoked_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenChainIsRevoked_ReturnsReauthRequired() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); - var store = factory.Create(validation.Tenant); - - var chain = CreateChain(validation, Now.AddMinutes(-10)); - await store.CreateChainAsync(chain); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-2)); + var store = factory.Create(validation.Tenant); var revoked = chain.Revoke(Now.AddMinutes(-1)); await store.SaveChainAsync(revoked, chain.Version); @@ -108,69 +115,114 @@ public async Task RefreshAsync_WhenChainIsRevoked_ReturnsSuccessWithoutTouch() var result = await sut.RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(), SessionTouchMode.IfNeeded, Now); - result.IsSuccess.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); result.DidTouch.Should().BeFalse(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); + var persisted = await store.GetChainAsync(chain.ChainId); - persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeTrue(); - persisted.LastSeenAt.Should().Be(revoked.LastSeenAt); persisted.TouchCount.Should().Be(revoked.TouchCount); persisted.Version.Should().Be(revoked.Version); } [Fact] - public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWithoutTouch() + public async Task RefreshAsync_WhenSessionIsRevoked_ReturnsReauthRequired() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); + var (factory, validation, _, session) = + await CreateValidSessionAsync(Now.AddMinutes(-2)); + var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-4)); - await store.CreateChainAsync(chain); + var revoked = session.Revoke(Now.AddMinutes(-1)); - var sut = new SessionTouchService(factory); + await store.SaveSessionAsync(revoked, session.Version); - var result = await sut.RefreshAsync( + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenSessionIsExpired_ReturnsReauthRequired() + { + var (factory, validation, _, _) = + await CreateValidSessionAsync( + Now.AddMinutes(-2), + sessionExpiresAt: Now); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.Force, Now); + + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenChainIdleTimeoutElapsed_ReturnsReauthRequired() + { + var (factory, validation, _, _) = + await CreateValidSessionAsync(Now.AddMinutes(-11)); + + var result = await new SessionTouchService(factory).RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, - SessionTouchMode.IfNeeded, + Policy(idleTimeout: TimeSpan.FromMinutes(10)), + SessionTouchMode.Force, Now); + result.IsSuccess.Should().BeFalse(); + result.RequiresReauth.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWithoutTouch() + { + var (factory, validation, chain, session) = + await CreateValidSessionAsync(Now.AddMinutes(-4)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + result.IsSuccess.Should().BeTrue(); + result.RequiresReauth.Should().BeFalse(); result.DidTouch.Should().BeFalse(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); - persisted!.LastSeenAt.Should().Be(chain.LastSeenAt); - persisted.TouchCount.Should().Be(0); + var store = factory.Create(validation.Tenant); + var persistedChain = await store.GetChainAsync(chain.ChainId); + var persistedSession = await store.GetSessionAsync(session.SessionId); + + persistedChain!.LastSeenAt.Should().Be(chain.LastSeenAt); + persistedChain.TouchCount.Should().Be(0); + persistedChain.Version.Should().Be(chain.Version); + + persistedSession!.ExpiresAt.Should().Be(session.ExpiresAt); + persistedSession.Version.Should().Be(session.Version); } [Fact] public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); - var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-6)); - await store.CreateChainAsync(chain); - - var sut = new SessionTouchService(factory); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-6)); - var result = await sut.RefreshAsync( - validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, - SessionTouchMode.IfNeeded, - Now); + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); result.SessionId.Should().Be(validation.SessionId); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + persisted!.LastSeenAt.Should().Be(Now); persisted.TouchCount.Should().Be(1); persisted.Version.Should().Be(chain.Version + 1); @@ -179,26 +231,137 @@ public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain( [Fact] public async Task RefreshAsync_WhenTouchIntervalExactlyElapsed_TouchesChain() { - var factory = new InMemorySessionStoreFactory(); - var validation = CreateActiveValidation(); + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-5)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalIsNull_TouchesEveryRefresh() + { + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-1)); + + var policy = PolicyWithoutTouchInterval(); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, policy, SessionTouchMode.IfNeeded, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + } + + [Fact] + public async Task RefreshAsync_WhenForceIsUsed_TouchesBeforeInterval() + { + var (factory, validation, chain, _) = + await CreateValidSessionAsync(Now.AddMinutes(-1)); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, Policy(), SessionTouchMode.Force, Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await factory.Create(validation.Tenant) + .GetChainAsync(chain.ChainId); + + persisted!.LastSeenAt.Should().Be(Now); + } + + [Fact] + public async Task RefreshAsync_WhenSlidingEnabled_ExtendsSessionExpiration() + { + var originalExpiry = Now.AddMinutes(10); + + var (factory, validation, chain, session) = + await CreateValidSessionAsync( + Now.AddMinutes(-6), + sessionExpiresAt: originalExpiry); + + var result = await new SessionTouchService(factory).RefreshAsync( + validation, + Policy( + slidingExpiration: true, + lifetime: TimeSpan.FromHours(1)), + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + var store = factory.Create(validation.Tenant); - var chain = CreateChain(validation, Now.AddMinutes(-5)); - await store.CreateChainAsync(chain); + var persistedSession = await store.GetSessionAsync(session.SessionId); + var persistedChain = await store.GetChainAsync(chain.ChainId); - var sut = new SessionTouchService(factory); + persistedSession!.ExpiresAt.Should().Be(Now.AddHours(1)); + persistedSession.Version.Should().Be(session.Version + 1); + persistedChain!.LastSeenAt.Should().Be(Now); + } - var result = await sut.RefreshAsync( + [Fact] + public async Task RefreshAsync_WhenSlidingDisabled_DoesNotExtendSession() + { + var originalExpiry = Now.AddMinutes(10); + + var (factory, validation, _, session) = + await CreateValidSessionAsync( + Now.AddMinutes(-6), + sessionExpiresAt: originalExpiry); + + var result = await new SessionTouchService(factory).RefreshAsync( validation, - new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + Policy(slidingExpiration: false), SessionTouchMode.IfNeeded, Now); result.IsSuccess.Should().BeTrue(); result.DidTouch.Should().BeTrue(); - var persisted = await store.GetChainAsync(validation.ChainId!.Value); - persisted!.LastSeenAt.Should().Be(Now); - persisted.TouchCount.Should().Be(1); + var persisted = await factory.Create(validation.Tenant) + .GetSessionAsync(session.SessionId); + + persisted!.ExpiresAt.Should().Be(originalExpiry); + persisted.Version.Should().Be(session.Version); + } + + private static SessionTouchPolicy Policy(TimeSpan? idleTimeout = null, TimeSpan? lifetime = null, bool slidingExpiration = false) + { + return new SessionTouchPolicy + { + TouchInterval = TimeSpan.FromMinutes(5), + IdleTimeout = idleTimeout ?? TimeSpan.FromMinutes(30), + Lifetime = lifetime ?? TimeSpan.FromHours(1), + SlidingExpiration = slidingExpiration + }; + } + + private static SessionTouchPolicy PolicyWithoutTouchInterval() + { + return new SessionTouchPolicy + { + TouchInterval = null, + IdleTimeout = TimeSpan.FromMinutes(30), + Lifetime = TimeSpan.FromHours(1), + SlidingExpiration = false + }; } private static SessionValidationResult CreateActiveValidation() @@ -214,7 +377,9 @@ private static SessionValidationResult CreateActiveValidation() boundDeviceId: TestDevice.Default().DeviceId); } - private static UAuthSessionChain CreateChain(SessionValidationResult validation, DateTimeOffset lastSeenAt) + private static UAuthSessionChain CreateChain( + SessionValidationResult validation, + DateTimeOffset lastSeenAt) { return UAuthSessionChain.Create( chainId: validation.ChainId!.Value, @@ -227,4 +392,43 @@ private static UAuthSessionChain CreateChain(SessionValidationResult validation, claimsSnapshot: ClaimsSnapshot.Empty, securityVersion: 0); } + + private static async Task<(InMemorySessionStoreFactory Factory, SessionValidationResult Validation, UAuthSessionChain Chain, + UAuthSession Session)> CreateValidSessionAsync(DateTimeOffset lastSeenAt, DateTimeOffset? sessionExpiresAt = null) + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + + // 1. Create and persist the initial chain (Version = 0). + var chain = CreateChain(validation, lastSeenAt); + await store.CreateChainAsync(chain); + + // 2. Create and persist the session (Version = 0). + var session = UAuthSession.Create( + sessionId: validation.SessionId!.Value, + tenant: validation.Tenant, + userKey: validation.UserKey!.Value, + chainId: validation.ChainId!.Value, + now: lastSeenAt, + expiresAt: sessionExpiresAt ?? Now.AddHours(1), + securityVersion: 0, + device: TestDevice.Default(), + claims: ClaimsSnapshot.Empty, + metadata: SessionMetadata.Empty); + + await store.CreateSessionAsync(session); + + // 3. Attach the session to the existing chain. + var activeChain = chain.AttachSession( + session.SessionId, + lastSeenAt); + + // Optimistic concurrency: expected version is 0. + await store.SaveChainAsync( + activeChain, + chain.Version); + + return (factory, validation, activeChain, session); + } } From 628e78ae3634862ed56ee7afab7798e82a439901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:40:40 +0300 Subject: [PATCH 13/20] Fix Refresh Flow 3 (#71) * fix: validate session touch interval against idle timeout * fix: treat zero idle timeout as disabled * fix(auth): align refresh token resolution with credential delivery * fix(auth): partially prevent credential loss during failed refresh rotation * Additional Test --- .../Domain/Session/UAuthSessionChain.cs | 2 +- .../Flows/Refresh/RefreshTokenResolver.cs | 65 ++++-- .../UAuthServerSessionOptionsValidator.cs | 7 + .../Services/RefreshFlowService.cs | 26 +-- .../Services/RefreshTokenRotationService.cs | 4 +- .../Stores/EfCoreRefreshTokenStore.cs | 4 + .../InMemoryRefreshTokenStore.cs | 5 + .../RefreshTests.cs | 88 +++++++ .../SessionSlidingExpirationTests.cs | 24 ++ .../Core/UAuthSessionChainTests.cs | 31 +++ .../Server/RefreshFlowServiceTests.cs | 55 +++-- .../Server/RefreshTokenResolverTests.cs | 221 +++++++++++++++--- .../RefreshTokenRotationServiceTests.cs | 63 ++++- .../Server/ServerOptionsValidatorTests.cs | 33 +++ 14 files changed, 548 insertions(+), 80 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs index 04ac32cc..afad62a7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -264,7 +264,7 @@ public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) if (IsExpired(at)) return SessionState.Expired; - if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + if (idleTimeout is { } idle && idle > TimeSpan.Zero && at - LastSeenAt >= idle) return SessionState.Expired; return SessionState.Active; diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs index 3140b965..a9b1c5fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -6,35 +8,62 @@ namespace CodeBeam.UltimateAuth.Server.Flows; internal sealed class RefreshTokenResolver : IRefreshTokenResolver { - private const string DefaultCookieName = "uar"; - private const string BearerPrefix = "Bearer "; - private const string RefreshHeaderName = "X-Refresh-Token"; + private readonly IAuthFlowContextAccessor _authContext; + + public RefreshTokenResolver(IAuthFlowContextAccessor authContext) + { + _authContext = authContext; + } public string? Resolve(HttpContext context) { - if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) && - !string.IsNullOrWhiteSpace(cookieToken)) - { - return cookieToken; - } + var flow = _authContext.Current; + if (flow is null) + return null; - if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader)) + // Explicit refresh credential takes priority. + var explicitToken = ReadHeader(context, "X-Refresh-Token"); + + if (explicitToken is not null) + return explicitToken; + + var delivery = flow.Response.RefreshTokenDelivery; + + if (delivery.Mode == TokenResponseMode.Cookie) { - var value = authHeader.ToString(); - if (value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + var cookieName = delivery.Cookie?.Name; + + if (!string.IsNullOrWhiteSpace(cookieName) && + context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) && + !string.IsNullOrWhiteSpace(cookieToken)) { - var token = value.Substring(BearerPrefix.Length).Trim(); - if (!string.IsNullOrWhiteSpace(token)) - return token; + return cookieToken; } } - if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) && - !string.IsNullOrWhiteSpace(headerToken)) + // Explicitly configured refresh-token response header. + if (delivery.Mode == TokenResponseMode.Header && !string.IsNullOrWhiteSpace(delivery.Name)) { - return headerToken.ToString(); + return ReadHeader(context, delivery.Name); } return null; } + + private static string? ReadHeader(HttpContext context, string name) + { + if (!context.Request.Headers.TryGetValue(name, out var values)) + return null; + + var value = values.ToString().Trim(); + + if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + value = value["Bearer ".Length..].Trim(); + } + + return string.IsNullOrWhiteSpace(value) + ? null + : value; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs index 9ab74357..b2d9970b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -21,6 +21,13 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) errors.Add("Session.IdleTimeout cannot be negative."); + if (options.Session.IdleTimeout is { } idle && + options.Session.TouchInterval is { } touch && + idle > TimeSpan.Zero && touch >= idle) + { + errors.Add("Session.TouchInterval must be less than Session.IdleTimeout."); + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs index 1e6f0d1f..4538578b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -130,6 +130,19 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re if (!validation.IsValid) return RefreshFlowResult.ReauthRequired(); + var touchPolicy = new SessionTouchPolicy + { + TouchInterval = flow.Options.Session.TouchInterval, + Lifetime = flow.Options.Session.Lifetime, + SlidingExpiration = flow.Options.Session.SlidingExpiration, + IdleTimeout = flow.Options.Session.IdleTimeout + }; + + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); + + if (!refresh.IsSuccess || refresh.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + var rotation = await _tokenRotation.RotateAsync( flow, new RefreshTokenRotationContext @@ -144,19 +157,6 @@ private async Task HandleHybridAsync(AuthFlowContext flow, Re if (!rotation.Result.IsSuccess) return RefreshFlowResult.ReauthRequired(); - var touchPolicy = new SessionTouchPolicy - { - TouchInterval = flow.Options.Session.TouchInterval, - Lifetime = flow.Options.Session.Lifetime, - SlidingExpiration = flow.Options.Session.SlidingExpiration, - IdleTimeout = flow.Options.Session.IdleTimeout - }; - - var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); - - if (!refresh.IsSuccess || refresh.SessionId is null) - return RefreshFlowResult.ReauthRequired(); - //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); return RefreshFlowResult.Success( diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 4d279ddc..56471b93 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -98,6 +98,8 @@ public async Task RotateAsync(AuthFlowContext flo return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() }; } + var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + // Only one concurrent request is allowed to consume // the current refresh token. var consumed = await store.ExecuteAsync( @@ -130,8 +132,6 @@ public async Task RotateAsync(AuthFlowContext flo return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() }; } - // Only the winning request needs an access token. - var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); var refreshTokenInfo = new RefreshTokenInfo { Token = refreshToken.Token, diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs index ae4d5a16..328f69eb 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -20,6 +20,10 @@ public EfCoreRefreshTokenStore(TDbContext db, TenantExecutionContext tenant) private DbSet DbSet => _db.Set(); + // TODO: Review execution strategy retries and commit-unknown scenarios. + // Ensure failed transaction attempts do not retain stale tracked entities, + // and evaluate recovery when the database commit succeeds but the client + // does not receive the replacement refresh token. public async Task ExecuteAsync(Func action, CancellationToken ct = default) { var strategy = _db.Database.CreateExecutionStrategy(); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index e5cb2903..294aaa4e 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -18,6 +18,11 @@ public InMemoryRefreshTokenStore(TenantKey tenant) _tenant = tenant; } + // TODO: Implement transactional rollback for InMemory refresh token operations. + // SemaphoreSlim provides serialization but does not guarantee atomicity. + // A failure or cancellation between consuming the current token and storing + // its replacement may leave the refresh token chain unusable. + // Ensure all mutations participate in the same transaction boundary. public async Task ExecuteAsync(Func action, CancellationToken ct = default) { await _tx.WaitAsync(ct); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index a7b343ba..93c56ff5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -2469,6 +2469,73 @@ await attacker.PostAsync( .Be(HttpStatusCode.Unauthorized); } + [Fact] + public async Task Refresh_Hybrid_Maui_HeaderCredentials_ShouldSupportConsecutiveRefreshes() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"maui-refresh-{Guid.NewGuid():N}"); + + SetClientProfile(client, "Maui"); + + // 1. Login + using var login = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var sessionId = ReadCredential(login, "uas"); + var refreshToken = ReadCredential(login, "uar"); + + sessionId.Should().NotBeNullOrWhiteSpace(); + refreshToken.Should().NotBeNullOrWhiteSpace(); + + // 2. First refresh using the credentials delivered by login + using var firstRequest = new HttpRequestMessage( + HttpMethod.Post, "/auth/refresh"); + + firstRequest.Headers.TryAddWithoutValidation( + "X-UAuth-Session", sessionId); + + firstRequest.Headers.TryAddWithoutValidation( + "X-Refresh-Token", refreshToken); + + using var first = await client.SendAsync(firstRequest); + + first.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var rotatedRefreshToken = ReadCredential(first, "uar"); + + rotatedRefreshToken.Should().NotBeNullOrWhiteSpace(); + rotatedRefreshToken.Should().NotBe(refreshToken); + + // Session ID may be reissued depending on the refresh policy. + var nextSessionId = + TryReadCredential(first, "uas") ?? sessionId; + + // 3. Second refresh using the newly delivered refresh token + using var secondRequest = new HttpRequestMessage( + HttpMethod.Post, "/auth/refresh"); + + secondRequest.Headers.TryAddWithoutValidation( + "X-UAuth-Session", nextSessionId); + + secondRequest.Headers.TryAddWithoutValidation( + "X-Refresh-Token", rotatedRefreshToken); + + using var second = await client.SendAsync(secondRequest); + + second.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + private async Task LoginAsync(IntegrationTestUser user, string profile = "BlazorServer") @@ -2705,4 +2772,25 @@ private static void SetTenant(HttpClient client, string tenant) "X-Tenant", tenant); } + + private static string ReadCredential(HttpResponseMessage response, string headerName) + { + var value = TryReadCredential(response, headerName); + value.Should().NotBeNullOrWhiteSpace($"response must deliver the {headerName} credential"); + + return value!; + } + + private static string? TryReadCredential(HttpResponseMessage response, string headerName) + { + if (!response.Headers.TryGetValues(headerName, out var values)) + return null; + + var raw = values.Single().Trim(); + + if (raw.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + raw = raw["Bearer ".Length..].Trim(); + + return string.IsNullOrWhiteSpace(raw) ? null : raw; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs index 0ecd1bdd..ce08b0a6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionSlidingExpirationTests.cs @@ -415,6 +415,30 @@ public async Task Hybrid_ConcurrentRefresh_ShouldNotLeaveBrokenSessionGraph() next.StatusCode.Should().Be(HttpStatusCode.NoContent); } + [Theory] + [InlineData("BlazorServer")] + [InlineData("BlazorWasm")] + public async Task ZeroIdleTimeout_RefreshShouldRemainValidBeforeSessionExpiry(string profile) + { + await using var factory = AuthServerFactory.Create(o => + { + o.Session.Lifetime = TimeSpan.FromHours(1); + o.Session.MaxLifetime = null; + o.Session.IdleTimeout = TimeSpan.Zero; + o.Session.TouchInterval = TimeSpan.FromMinutes(5); + o.Session.SlidingExpiration = true; + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(7); + }); + + using var client = CreateClient(factory, profile); + await Login(factory, client); + + factory.Clock.Advance(TimeSpan.FromMinutes(30)); + + using var response = await Refresh(client, profile); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + private static AuthServerFactory CreateFactory(bool sliding, TimeSpan? maxLifetime = null) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs index 2219f194..21bca5cb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -147,4 +147,35 @@ public void Revoking_already_revoked_chain_is_idempotent() Assert.Same(revoked1, revoked2); } + + [Theory] + [InlineData(null, 60, SessionState.Active)] + [InlineData(0, 60, SessionState.Active)] + [InlineData(10, 9, SessionState.Active)] + [InlineData(10, 10, SessionState.Expired)] + [InlineData(10, 11, SessionState.Expired)] + public void GetState_ShouldRespectIdleTimeout(int? idleSeconds, int elapsedSeconds, SessionState expectedState) + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + now, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var idleTimeout = idleSeconds.HasValue + ? TimeSpan.FromSeconds(idleSeconds.Value) + : (TimeSpan?)null; + + var state = chain.GetState(now.AddSeconds(elapsedSeconds), idleTimeout); + + Assert.Equal(expectedState, state); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs index ce25294b..997462c2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -231,15 +231,26 @@ public async Task RefreshAsync_Hybrid_WhenSessionInvalid_ReturnsReauthWithoutRot } [Fact] - public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthWithoutTouch() + public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthAfterSuccessfulTouch() { var (sut, validator, touch, rotation) = CreateSut(); var validation = CreateActiveValidation(); validator - .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) .ReturnsAsync(validation); + touch + .Setup(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Success(SessionId, false)); + rotation .Setup(x => x.RotateAsync( It.IsAny(), @@ -258,7 +269,20 @@ public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthWithoutTouc CreateRequest(SessionId, "refresh-token")); AssertReauth(result); - touch.VerifyNoOtherCalls(); + + touch.Verify(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny()), + Times.Once); + + rotation.Verify(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); } [Fact] @@ -300,21 +324,16 @@ public async Task RefreshAsync_Hybrid_WhenAllStepsSucceed_ReturnsRotatedResultAn } [Fact] - public async Task RefreshAsync_Hybrid_WhenTouchFailsAfterRotation_ReturnsReauth() + public async Task RefreshAsync_Hybrid_WhenTouchFails_DoesNotRotateRefreshToken() { var (sut, validator, touch, rotation) = CreateSut(); var validation = CreateActiveValidation(); validator - .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(validation); - - rotation - .Setup(x => x.RotateAsync( - It.IsAny(), - It.IsAny(), + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), It.IsAny())) - .ReturnsAsync(CreateSuccessfulRotation()); + .ReturnsAsync(validation); touch .Setup(x => x.RefreshAsync( @@ -330,10 +349,20 @@ public async Task RefreshAsync_Hybrid_WhenTouchFailsAfterRotation_ReturnsReauth( CreateRequest(SessionId, "refresh-token")); AssertReauth(result); + + touch.Verify(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny()), + Times.Once); + rotation.Verify(x => x.RotateAsync( It.IsAny(), It.IsAny(), - It.IsAny()), Times.Once); + It.IsAny()), + Times.Never); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs index 01fd279b..5a5c3739 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs @@ -1,54 +1,121 @@ -using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Tests.Unit; public sealed class RefreshTokenResolverTests { - private readonly RefreshTokenResolver _sut = new(); + [Fact] + public void Resolve_WhenCustomCookieNameIsConfigured_ReturnsCookieToken() + { + var context = TestHttpContext.Create(); + context.Request.Headers.Cookie = "custom-refresh=refresh-token"; + + var result = Resolve( + context, + CookieDelivery("custom-refresh")); + + Assert.Equal("refresh-token", result); + } + + [Fact] + public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersRefreshHeader() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Authorization = "Bearer session-id"; + context.Request.Headers["X-Refresh-Token"] = "refresh-token"; + + var result = Resolve( + context, + CookieDelivery()); + + Assert.Equal("refresh-token", result); + } + + [Fact] + public void Resolve_WhenMauiRefreshHeaderIsConfigured_ReturnsRefreshToken() + { + var context = TestHttpContext.Create(); + + context.Request.Headers["uar"] = "Bearer refresh-token"; + + var result = Resolve( + context, + HeaderDelivery("uar")); + + Assert.Equal("refresh-token", result); + } [Fact] public void Resolve_WhenCookieContainsToken_ReturnsCookieToken() { - var context = CreateContext(); + var context = TestHttpContext.Create(); context.Request.Headers.Cookie = "uar=cookie-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); Assert.Equal("cookie-token", result); } [Fact] - public void Resolve_WhenAuthorizationContainsBearerToken_ReturnsBearerToken() + public void Resolve_WhenAuthorizationContainsBearerToken_ReturnsNull() { var context = CreateContext(); context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); - Assert.Equal("bearer-token", result); + Assert.Null(result); } - [Fact] - public void Resolve_WhenBearerSchemeUsesDifferentCasing_ReturnsBearerToken() + [Theory] + [InlineData("Bearer bearer-token")] + [InlineData("bEaReR bearer-token")] + public void Resolve_WhenAuthorizationIsNotConfiguredAsRefreshHeader_ReturnsNull(string authorization) { - var context = CreateContext(); - context.Request.Headers.Authorization = "bEaReR bearer-token"; + var context = TestHttpContext.Create(); + context.Request.Headers.Authorization = authorization; + + var result = Resolve(context, HeaderDelivery("uar")); + + Assert.Null(result); + } + + [Theory] + [InlineData("Bearer bearer-token")] + [InlineData("bEaReR bearer-token")] + public void Resolve_WhenAuthorizationIsConfiguredAsRefreshHeader_ReturnsToken(string authorization) + { + var context = TestHttpContext.Create(); + context.Request.Headers.Authorization = authorization; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("Authorization")); Assert.Equal("bearer-token", result); } - [Fact] - public void Resolve_WhenRefreshHeaderContainsToken_ReturnsHeaderToken() + [Theory] + [InlineData("header-token", "header-token")] + [InlineData("Bearer header-token", "header-token")] + [InlineData("bEaReR header-token", "header-token")] + public void Resolve_WhenRefreshHeaderContainsToken_ReturnsHeaderToken( + string header, + string expected) { var context = CreateContext(); - context.Request.Headers["X-Refresh-Token"] = "header-token"; + context.Request.Headers["X-Refresh-Token"] = header; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("X-Refresh-Token")); - Assert.Equal("header-token", result); + Assert.Equal(expected, result); } [Fact] @@ -56,7 +123,7 @@ public void Resolve_WhenNoTokenExists_ReturnsNull() { var context = CreateContext(); - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); Assert.Null(result); } @@ -68,21 +135,22 @@ public void Resolve_WhenCookieAndBearerExist_PrefersCookie() context.Request.Headers.Cookie = "uar=cookie-token"; context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); Assert.Equal("cookie-token", result); } [Fact] - public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersBearer() + public void Resolve_WhenCookieAndRefreshHeaderExist_PrefersRefreshHeader() { - var context = CreateContext(); - context.Request.Headers.Authorization = "Bearer bearer-token"; + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=cookie-token"; context.Request.Headers["X-Refresh-Token"] = "header-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); - Assert.Equal("bearer-token", result); + Assert.Equal("header-token", result); } [Theory] @@ -93,13 +161,13 @@ public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersBearer() [InlineData("Bearer")] [InlineData("Bearer ")] [InlineData("Bearer ")] - public void Resolve_WhenAuthorizationDoesNotContainValidBearerToken_ReturnsNull( - string authorization) + public void Resolve_WhenAuthorizationIsPresentWithoutRefreshCredential_ReturnsNull( + string authorization) { var context = CreateContext(); context.Request.Headers.Authorization = authorization; - var result = _sut.Resolve(context); + var result = Resolve(context, CredentialResponseOptions.Disabled(GrantKind.RefreshToken)); Assert.Null(result); } @@ -111,23 +179,112 @@ public void Resolve_WhenBearerTokenIsEmpty_FallsBackToRefreshHeader() context.Request.Headers.Authorization = "Bearer "; context.Request.Headers["X-Refresh-Token"] = "header-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, HeaderDelivery("X-Refresh-Token")); Assert.Equal("header-token", result); } [Fact] - public void Resolve_WhenCookieIsEmpty_FallsBackToBearerToken() + public void Resolve_WhenCookieIsEmpty_DoesNotUseAuthorizationAsRefreshToken() { - var context = CreateContext(); + var context = TestHttpContext.Create(); + context.Request.Headers.Cookie = "uar="; context.Request.Headers.Authorization = "Bearer bearer-token"; - var result = _sut.Resolve(context); + var result = Resolve(context, CookieDelivery("uar")); - Assert.Equal("bearer-token", result); + Assert.Null(result); + } + + [Fact] + public void Resolve_WhenCustomCookieIsConfigured_DoesNotReadDefaultCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=wrong-token"; + + var result = Resolve(context, CookieDelivery("custom-refresh")); + + Assert.Null(result); } + [Fact] + public void Resolve_WhenExplicitRefreshHeaderIsEmpty_FallsBackToCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Cookie = "uar=cookie-token"; + context.Request.Headers["X-Refresh-Token"] = " "; + + var result = Resolve(context, CookieDelivery("uar")); + + Assert.Equal("cookie-token", result); + } + + [Fact] + public void Resolve_WhenBearerSessionAndRefreshCookieExist_ReturnsRefreshCookie() + { + var context = TestHttpContext.Create(); + + context.Request.Headers.Authorization = "Bearer session-id"; + context.Request.Headers.Cookie = "uar=refresh-token"; + + var result = Resolve(context, CookieDelivery("uar")); + + Assert.Equal("refresh-token", result); + } + + private static DefaultHttpContext CreateContext() => new(); + + private static string? Resolve(HttpContext context, CredentialResponseOptions refreshDelivery) + { + var response = new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: refreshDelivery, + redirect: EffectiveRedirectResponse.Disabled + ); + + var flow = AuthFlowTestFactory.New(response: response, mode: UAuthMode.Hybrid); + + var httpAccessor = new HttpContextAccessor + { + HttpContext = context + }; + + var authAccessor = new AuthFlowContextAccessor(httpAccessor); + authAccessor.Set(flow); + + var resolver = new RefreshTokenResolver(authAccessor); + + return resolver.Resolve(context); + } + + private static CredentialResponseOptions CookieDelivery(string cookieName = "uar") + { + return new CredentialResponseOptions + { + Kind = GrantKind.RefreshToken, + Mode = TokenResponseMode.Cookie, + TokenFormat = TokenFormat.Opaque + }.WithCookie(new UAuthCookieOptions + { + Name = cookieName + }); + } + + private static CredentialResponseOptions HeaderDelivery(string headerName = "uar") + { + return new CredentialResponseOptions + { + Kind = GrantKind.RefreshToken, + Mode = TokenResponseMode.Header, + TokenFormat = TokenFormat.Opaque, + HeaderFormat = HeaderTokenFormat.Bearer, + Name = headerName + }; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 4c96b47c..5442498c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -540,7 +540,7 @@ public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutSto It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); + Times.Once); } [Fact] @@ -559,6 +559,67 @@ public void RefreshTokenInfo_DoesNotExposeTokenHash() typeof(RefreshTokenInfo).GetProperty("TokenHash").Should().BeNull(); } + [Fact] + public async Task RotateAsync_WhenAccessTokenIssuanceThrows_DoesNotConsumeRefreshToken() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + + var validator = CreateValidator(validation); + var store = CreateExecutableStore(); + var storeFactory = CreateStoreFactory(tenant, store); + var claimsProvider = new Mock(); + var issuer = new Mock(); + + var refreshToken = CreateRefreshTokenIssuanceResult(); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync(refreshToken); + + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("AT issuance failed")); + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object, + claimsProvider.Object); + + var act = () => sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); + + await act.Should() + .ThrowAsync() + .WithMessage("AT issuance failed"); + + store.Verify(x => x.TryConsumeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + store.Verify(x => x.StoreAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + store.Verify(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny()), + Times.Never); + } + private static Mock CreateValidator(RefreshTokenValidationResult result) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 7296dd99..781d89f2 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -133,6 +133,39 @@ public void Pkce_authorization_code_lifetime_must_be_positive() Assert.Contains("Pkce.AuthorizationCodeLifetimeSeconds must be > 0", ex.Message); } + [Theory] + [InlineData(10, 10)] + [InlineData(15, 10)] + public void TouchInterval_GreaterThanOrEqualToIdleTimeout_ShouldFailValidation(int touchSeconds, int idleSeconds) + { + var services = new ServiceCollection(); + + services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection() + .Build()); + + services.AddOptions() + .Configure(o => + { + o.Session.TouchInterval = TimeSpan.FromSeconds(touchSeconds); + o.Session.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); + }); + + services.AddSingleton< + IValidateOptions, + UAuthServerSessionOptionsValidator>(); + + using var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws(() => + _ = provider.GetRequiredService>().Value); + + Assert.Contains( + "Session.TouchInterval must be less than Session.IdleTimeout.", + ex.Message); + } + [Fact] public void MultiTenant_enabled_without_resolver_should_fail() { From 6b3364832abbd70a1d8254aaf68684bec348a5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:15:59 +0300 Subject: [PATCH 14/20] Fix Endpoint Contracts (#72) --- .../Auth/Response/AuthResponseResolver.cs | 16 +++++++++++++- .../ClientProfileAuthResponseAdapter.cs | 12 +++++++++++ .../Endpoints/UAuthEndpointRegistrar.cs | 6 +++--- .../Options/UAuthCookieOptions.cs | 3 ++- .../Services/UAuthAuthorizationClient.cs | 2 +- .../Services/UAuthUserIdentifierClient.cs | 2 +- .../UserLifecycleTests.cs | 21 +++++++++++++++++++ 7 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 41e7e278..f0c4f6d7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Net.Http.Headers; namespace CodeBeam.UltimateAuth.Server.Auth; @@ -76,10 +77,23 @@ private static void ValidateDelivery(CredentialResponseOptions delivery) if (delivery.Mode == TokenResponseMode.Body) throw new NotSupportedException($"Body credential delivery is not supported for '{delivery.Kind}'."); - if (delivery.Mode == TokenResponseMode.Cookie && delivery.Cookie is null) + if (delivery.Mode != TokenResponseMode.Cookie) + return; + + if (delivery.Cookie is null) { throw new InvalidOperationException($"Credential '{delivery.Kind}' is configured as Cookie but no cookie options were bound."); } + + try + { + var cookie = new SetCookieHeaderValue(delivery.Cookie.Name, "validation"); + _ = cookie.ToString(); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Invalid cookie name configured for '{delivery.Kind}': '{delivery.Cookie.Name}'.", ex); + } } private static EffectiveRedirectResponse ResolveRedirect(AuthFlowType flowType, UAuthResponseOptions bound) diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs index cb64e072..7799673d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -12,6 +12,10 @@ public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProf { var configured = effectiveOptions.Options.AuthResponse; + ValidateConfiguredDelivery(configured.SessionIdDelivery); + ValidateConfiguredDelivery(configured.AccessTokenDelivery); + ValidateConfiguredDelivery(configured.RefreshTokenDelivery); + return new UAuthResponseOptions { SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, GrantKind.Session, clientProfile), @@ -77,4 +81,12 @@ private static LogoutRedirectOptions MergeLogout(LogoutRedirectOptions template, AllowReturnUrlOverride = configured.AllowReturnUrlOverride }; } + + private static void ValidateConfiguredDelivery(CredentialResponseOptions delivery) + { + if (delivery.Mode == TokenResponseMode.Body) + { + throw new NotSupportedException($"Body credential delivery is not currently supported for '{delivery.Kind}'."); + } + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index 686deff8..4fe2f145 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -72,8 +72,8 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); - group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) - => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); + //group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) + // => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) @@ -206,7 +206,7 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Users.CreateAdmin)) adminUsers.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.CreateAdminAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs index cd0af2ed..c48afbf5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs @@ -36,6 +36,7 @@ public sealed class UAuthCookieOptions SameSite = SameSite, Path = Path, MaxAge = MaxAge, - Lifetime = Lifetime.Clone() + Lifetime = Lifetime.Clone(), + Domain = Domain }; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index 306dbc3b..bc4d79c7 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -25,7 +25,7 @@ public UAuthAuthorizationClient(IUAuthRequestClient request, IUAuthClientEvents public async Task> CheckAsync(AuthorizationCheckRequest request) { - var raw = await _request.SendJsonAsync(Url("/authorization/check"), request); + var raw = await _request.SendJsonAsync(Url("/me/authorization/check"), request); return UAuthResultMapper.FromJson(raw); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs index 2c21b0f9..01ea8d27 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -149,7 +149,7 @@ public async Task DeleteUserAsync(UserKey userKey, DeleteUserIdenti public async Task> CheckAvailabilityAsync(CheckUserIdentifierAvailabilityRequest request) { - var raw = await _request.SendJsonAsync(Url("/users/identifiers/check-availability"), request); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/check-availability"), request); return UAuthResultMapper.FromJson(raw); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs index 2d97dd87..3cb5251e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -641,6 +641,27 @@ await profileFactory .Equal($"{prefix}-Delta", $"{prefix}-Echo"); } + [Fact] + public async Task AdminCreate_WhenAnonymous_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var response = await client.PostAsJsonAsync( + "/auth/admin/users/create", + new CreateUserRequest + { + UserName = $"admin-create-{Guid.NewGuid():N}", + Password = $"Test-{Guid.NewGuid():N}!", + DisplayName = "Unauthorized Creation" + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + // ------------------------------------------------------- // Helpers // ------------------------------------------------------- From 1989a5b7d8a387c98ef1f29c46c569fd123e3f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:56:19 +0300 Subject: [PATCH 15/20] Public API Naming Consistency & Fix Missing Namespaces (#73) --- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Pages/ResetCredential.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Components/Pages/ResetCredential.razor.cs | 2 +- .../Program.cs | 1 + .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Pages/ResetCredential.razor.cs | 2 +- .../Dialogs/CredentialDialog.razor.cs | 2 +- .../Components/Dialogs/ResetDialog.razor.cs | 2 +- .../Pages/ResetCredential.razor.cs | 2 +- .../Endpoints/LogoutEndpointHandler.cs | 5 ++- ...eleteRoleResult.cs => RoleDeleteResult.cs} | 2 +- ...RolesResponse.cs => UserRolesGetResult.cs} | 2 +- .../Endpoints/AuthorizationEndpointHandler.cs | 4 +-- .../Services/RoleService.cs | 6 ++-- .../Abstractions/IRoleService.cs | 2 +- .../Abstractions/IAuthorizationClient.cs | 6 ++-- .../Abstractions/ICredentialClient.cs | 16 +++++----- .../Services/UAuthAuthorizationClient.cs | 12 +++---- .../Services/UAuthCredentialClient.cs | 26 +++++++-------- ...entialResult.cs => CredentialAddResult.cs} | 6 ++-- ...esult.cs => CredentialBeginResetResult.cs} | 2 +- ...ialResult.cs => CredentialChangeResult.cs} | 6 ++-- ...tialsResult.cs => CredentialsGetResult.cs} | 2 +- .../Services/CredentialManagementService.cs | 24 +++++++------- .../Services/ICredentialManagementService.cs | 8 ++--- .../Requests/LogoutOtherDevicesRequest.cs | 2 +- .../Requests}/UserLifecycleQuery.cs | 2 +- .../Requests}/UserProfileQuery.cs | 3 +- .../Responses/BeginMfaSetupResult.cs | 8 ----- ...lt.cs => UserIdentifierExistenceResult.cs} | 2 +- ...nse.cs => UserIdentifierExistsResponse.cs} | 2 +- .../Stores/EfCoreUserIdentifierStore.cs | 6 ++-- .../Stores/EfCoreUserLifecycleStore.cs | 1 + .../Stores/InMemoryUserIdentifierStore.cs | 6 ++-- .../Stores/InMemoryUserLifecycleStore.cs | 1 + .../Endpoints/UserEndpointHandler.cs | 4 +-- .../UserIdentifierAvailabilityService.cs | 3 +- .../Stores/IUserIdentifierStore.cs | 2 +- .../Stores/IUserLifecycleStore.cs | 1 + .../Abstractions/IUserIdentifierVerifier.cs | 2 ++ .../FailingUserIdentifierStore.cs | 2 +- .../AuthorizationManagementTests.cs | 4 +-- .../CredentialManagementTests.cs | 4 +-- .../FailingUserIdentifierStore.cs | 2 +- .../LogoutAdminTests.cs | 10 +++--- .../SdkEnpointContractTests.cs | 4 +-- .../UserIdentifierTests.cs | 1 + .../AuthorizationEndpointHandlerTests.cs | 2 +- .../Authorization/RoleServiceTests.cs | 4 +-- .../Client/UAuthClientAuthorizationTests.cs | 6 ++-- .../Client/UAuthClientCredentialsTests.cs | 18 +++++------ .../UAuthClientEventConsistencyTests.cs | 4 +-- .../Client/UAuthFlowClientTests.cs | 2 +- .../CredentialEndpointHandlerTests.cs | 32 +++++++++---------- .../CredentialManagementServiceTests.cs | 16 +++++----- .../Server/LogoutEndpointHandlerTests.cs | 2 +- .../Store/UserLifecycleStoreContractTests.cs | 1 + .../Users/UserApplicationServiceTests.cs | 10 +++--- .../Users/UserEndpointHandlerTests.cs | 4 +-- 63 files changed, 163 insertions(+), 164 deletions(-) rename src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/{DeleteRoleResult.cs => RoleDeleteResult.cs} (88%) rename src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/{UserRolesResponse.cs => UserRolesGetResult.cs} (87%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/{AddCredentialResult.cs => CredentialAddResult.cs} (77%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/{BeginCredentialResetResult.cs => CredentialBeginResetResult.cs} (70%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/{ChangeCredentialResult.cs => CredentialChangeResult.cs} (71%) rename src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/{GetCredentialsResult.cs => CredentialsGetResult.cs} (80%) rename src/users/{CodeBeam.UltimateAuth.Users.Reference/Contracts => CodeBeam.UltimateAuth.Users.Contracts/Requests}/UserLifecycleQuery.cs (80%) rename src/users/{CodeBeam.UltimateAuth.Users.Reference/Contracts => CodeBeam.UltimateAuth.Users.Contracts/Requests}/UserProfileQuery.cs (67%) delete mode 100644 src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/{IdentifierExistenceResult.cs => UserIdentifierExistenceResult.cs} (80%) rename src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/{IdentifierExistsResponse.cs => UserIdentifierExistsResponse.cs} (65%) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs index 5e931ea6..13b4f69f 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs @@ -62,7 +62,7 @@ private async Task ChangePasswordAsync() }; } - UAuthResult result; + UAuthResult result; if (UserKey is null) { result = await UAuthClient.Credentials.ChangeMyAsync(request); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs index f70034d2..25c068f0 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs @@ -26,7 +26,7 @@ private async Task RequestResetAsync() Identifier = _identifier ?? string.Empty }; - var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + var result = await UAuthClient.Credentials.BeginResetAsync(request); if (!result.IsSuccess || result.Value is null) { Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); 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 9c453a4d..1569f1f4 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 @@ -38,7 +38,7 @@ private async Task ResetPasswordAsync() Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; - var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + var result = await UAuthClient.Credentials.CompleteResetAsync(request); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs index 887707c5..04ba3a44 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs @@ -62,7 +62,7 @@ private async Task ChangePasswordAsync() }; } - UAuthResult result; + UAuthResult result; if (UserKey is null) { result = await UAuthClient.Credentials.ChangeMyAsync(request); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs index 96e81a55..0cbbd926 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs @@ -26,7 +26,7 @@ private async Task RequestResetAsync() Identifier = _identifier ?? string.Empty }; - var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + var result = await UAuthClient.Credentials.BeginResetAsync(request); if (!result.IsSuccess || result.Value is null) { Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); 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 f02691c1..44c1f1a0 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 @@ -38,7 +38,7 @@ private async Task ResetPasswordAsync() Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; - var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + var result = await UAuthClient.Credentials.CompleteResetAsync(request); if (result.IsSuccess) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index 6613d3f2..2e5cef70 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -9,6 +9,7 @@ using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; using CodeBeam.UltimateAuth.Sample.Seed.Extensions; using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Users; using Microsoft.AspNetCore.HttpOverrides; using MudBlazor.Services; using MudExtensions.Services; diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs index a9c76d1c..e3b5c2ed 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs @@ -62,7 +62,7 @@ private async Task ChangePasswordAsync() }; } - UAuthResult result; + UAuthResult result; if (UserKey is null) { result = await UAuthClient.Credentials.ChangeMyAsync(request); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs index 7dab5f66..eafbd881 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs @@ -26,7 +26,7 @@ private async Task RequestResetAsync() Identifier = _identifier ?? string.Empty }; - var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + var result = await UAuthClient.Credentials.BeginResetAsync(request); if (!result.IsSuccess || result.Value is null) { Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); 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 231c8115..8a02dcf0 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 @@ -38,7 +38,7 @@ private async Task ResetPasswordAsync() Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; - var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + var result = await UAuthClient.Credentials.CompleteResetAsync(request); if (result.IsSuccess) { diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/CredentialDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/CredentialDialog.razor.cs index 93228c65..fa3f8117 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/CredentialDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/CredentialDialog.razor.cs @@ -62,7 +62,7 @@ private async Task ChangePasswordAsync() }; } - UAuthResult result; + UAuthResult result; if (UserKey is null) { result = await UAuthClient.Credentials.ChangeMyAsync(request); diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs index 17ff4866..349f56ff 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ResetDialog.razor.cs @@ -26,7 +26,7 @@ private async Task RequestResetAsync() Identifier = _identifier ?? string.Empty }; - var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + var result = await UAuthClient.Credentials.BeginResetAsync(request); if (!result.IsSuccess || result.Value is null) { Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); 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 401d8656..4e8847fe 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 @@ -38,7 +38,7 @@ private async Task ResetPasswordAsync() Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; - var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + var result = await UAuthClient.Credentials.CompleteResetAsync(request); if (result.IsSuccess) { diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 06f9b4fd..175b07d3 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -1,5 +1,4 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; @@ -132,7 +131,7 @@ public async Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userK resource: "flows", resourceId: userKey.Value); - await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, request.CurrentChainId, ctx.RequestAborted); + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, request.ExceptChainId, ctx.RequestAborted); return Results.Ok(); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/RoleDeleteResult.cs similarity index 88% rename from src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs rename to src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/RoleDeleteResult.cs index ce6c06b1..60d1bcc5 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/RoleDeleteResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed class DeleteRoleResult +public sealed class RoleDeleteResult { public RoleId RoleId { get; init; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesGetResult.cs similarity index 87% rename from src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs rename to src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesGetResult.cs index 1acc9da8..6eeaa3eb 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesGetResult.cs @@ -3,7 +3,7 @@ namespace CodeBeam.UltimateAuth.Authorization.Contracts; -public sealed record UserRolesResponse +public sealed record UserRolesGetResult { public required UserKey UserKey { get; init; } public required PagedResult Roles { 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 2c52e1b3..c221688d 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -79,7 +79,7 @@ public async Task GetMyRolesAsync(HttpContext ctx) ); var roles = await _userRoles.GetRolesAsync(accessContext, flow.UserKey!.Value, req, ctx.RequestAborted); - return Results.Ok(new UserRolesResponse + return Results.Ok(new UserRolesGetResult { UserKey = flow.UserKey!.Value, Roles = roles @@ -104,7 +104,7 @@ public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) var roles = await _userRoles.GetRolesAsync(accessContext, userKey, req, ctx.RequestAborted); - return Results.Ok(new UserRolesResponse + return Results.Ok(new UserRolesGetResult { UserKey = userKey, Roles = roles diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs index 6ffed5e2..37f97f82 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -63,11 +63,11 @@ public async Task RenameAsync(AccessContext context, RoleId roleId, string newNa await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default) + public async Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { var key = new RoleKey(context.ResourceTenant, roleId); var roleStore = _roleFactory.Create(context.ResourceTenant); @@ -81,7 +81,7 @@ public async Task DeleteAsync(AccessContext context, RoleId ro await userRoleStore.RemoveAssignmentsByRoleAsync(roleId, innerCt); await roleStore.DeleteAsync(key, role.Version, mode, _clock.UtcNow, innerCt); - return new DeleteRoleResult + return new RoleDeleteResult { RoleId = roleId, RemovedAssignments = removed, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs index 948959f6..cae77d01 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -9,7 +9,7 @@ public interface IRoleService Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default); - Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default); + Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default); Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs index 3e848763..8f339558 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -49,7 +49,7 @@ public interface IAuthorizationClient /// /// Results may be paginated. Use to control paging behavior. /// - Task> GetMyRolesAsync(PageRequest? request = null); + Task> GetMyRolesAsync(PageRequest? request = null); /// /// Retrieves roles assigned to a specific user. @@ -57,7 +57,7 @@ public interface IAuthorizationClient /// /// Requires appropriate administrative permissions. /// - Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); + Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); /// /// Assigns a role to a user. @@ -108,5 +108,5 @@ public interface IAuthorizationClient /// /// Deleting a role removes it from all users. /// - Task> DeleteRoleAsync(DeleteRoleRequest request); + Task> DeleteRoleAsync(DeleteRoleRequest request); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs index 5715505d..ec28848d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -39,7 +39,7 @@ public interface ICredentialClient /// /// Typically used for initial password setup or adding alternative credentials. /// - Task> AddMyAsync(AddCredentialRequest request); + Task> AddMyAsync(AddCredentialRequest request); /// /// Changes the credential of the current user. @@ -47,7 +47,7 @@ public interface ICredentialClient /// /// May require the current credential for verification depending on policy. /// - Task> ChangeMyAsync(ChangeCredentialRequest request); + Task> ChangeMyAsync(ChangeCredentialRequest request); /// /// Revokes the current user's credential. @@ -63,15 +63,15 @@ public interface ICredentialClient /// /// This typically issues a verification step (e.g., email or OTP). /// - Task> BeginResetMyAsync(BeginResetCredentialRequest request); + Task> BeginResetAsync(BeginResetCredentialRequest request); /// /// Completes the credential reset process for the current user. /// /// - /// Must be called after a successful . + /// Must be called after a successful . /// - Task> CompleteResetMyAsync(CompleteResetCredentialRequest request); + Task> CompleteResetAsync(CompleteResetCredentialRequest request); /// @@ -80,7 +80,7 @@ public interface ICredentialClient /// /// Requires administrative privileges. /// - Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); /// /// Changes the credential of a specific user. @@ -88,7 +88,7 @@ public interface ICredentialClient /// /// Typically used for administrative resets or overrides. /// - Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request); + Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request); /// /// Revokes a credential for a specific user. @@ -98,7 +98,7 @@ public interface ICredentialClient /// /// Starts the credential reset process for a specific user. /// - Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request); + Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request); /// /// Completes the credential reset process for a specific user. diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs index bc4d79c7..7f5fac85 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -29,18 +29,18 @@ public async Task> CheckAsync(AuthorizationChec return UAuthResultMapper.FromJson(raw); } - public async Task> GetMyRolesAsync(PageRequest? request = null) + public async Task> GetMyRolesAsync(PageRequest? request = null) { request ??= new PageRequest(); var raw = await _request.SendJsonAsync(Url("/me/authorization/roles/get"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) + public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) { request ??= new PageRequest(); var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey.Value}/roles/get"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task AssignRoleToUserAsync(AssignRoleRequest request) @@ -109,10 +109,10 @@ public async Task SetRolePermissionsAsync(SetRolePermissionsRequest return result; } - public async Task> DeleteRoleAsync(DeleteRoleRequest request) + public async Task> DeleteRoleAsync(DeleteRoleRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.Id.Value}/delete"), request); - var result = UAuthResultMapper.FromJson(raw); + var result = UAuthResultMapper.FromJson(raw); if (result.IsSuccess) { diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index dd70b892..264b7f44 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -23,16 +23,16 @@ public UAuthCredentialClient(IUAuthRequestClient request, IUAuthClientEvents eve private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); - public async Task> AddMyAsync(AddCredentialRequest request) + public async Task> AddMyAsync(AddCredentialRequest request) { var raw = await _request.SendJsonAsync(Url("/me/credentials/add"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeMyAsync(ChangeCredentialRequest request) + public async Task> ChangeMyAsync(ChangeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url("/me/credentials/change"), request); - var result = UAuthResultMapper.FromJson(raw); + var result = UAuthResultMapper.FromJson(raw); if (result.IsSuccess) { @@ -57,13 +57,13 @@ await _events.PublishAsync( return result; } - public async Task> BeginResetMyAsync(BeginResetCredentialRequest request) + public async Task> BeginResetAsync(BeginResetCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/begin"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task> CompleteResetMyAsync(CompleteResetCredentialRequest request) + public async Task> CompleteResetAsync(CompleteResetCredentialRequest request) { var raw = await _request.SendJsonAsync(Url("/me/credentials/reset/complete"), request); @@ -78,16 +78,16 @@ await _events.PublishAsync( return result; } - public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/add"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } - public async Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request) + public async Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/change"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) @@ -96,10 +96,10 @@ public async Task RevokeUserAsync(UserKey userKey, RevokeCredential return UAuthResultMapper.From(raw); } - public async Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request) + public async Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request) { var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/reset/begin"), request); - return UAuthResultMapper.FromJson(raw); + return UAuthResultMapper.FromJson(raw); } public async Task> CompleteResetUserAsync(UserKey userKey, CompleteResetCredentialRequest request) diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialAddResult.cs similarity index 77% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialAddResult.cs index cb969cae..db0e9781 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialAddResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record AddCredentialResult +public sealed record CredentialAddResult { public bool IsSuccess { get; init; } @@ -11,7 +11,7 @@ public sealed record AddCredentialResult public Guid? Id { get; set; } public CredentialType? Type { get; init; } - public static AddCredentialResult Success(Guid id, CredentialType type) + public static CredentialAddResult Success(Guid id, CredentialType type) => new() { IsSuccess = true, @@ -20,7 +20,7 @@ public static AddCredentialResult Success(Guid id, CredentialType type) Error = null }; - public static AddCredentialResult Fail(string error) + public static CredentialAddResult Fail(string error) => new() { IsSuccess = false, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialBeginResetResult.cs similarity index 70% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialBeginResetResult.cs index db8d2c8c..16361ce5 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialBeginResetResult.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record BeginCredentialResetResult +public sealed record CredentialBeginResetResult { public DateTimeOffset ExpiresAt { get; init; } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs similarity index 71% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs index 6a78c338..1cf286d3 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record ChangeCredentialResult +public sealed record CredentialChangeResult { public bool IsSuccess { get; init; } @@ -10,14 +10,14 @@ public sealed record ChangeCredentialResult public CredentialType? Type { get; init; } - public static ChangeCredentialResult Success(CredentialType type) + public static CredentialChangeResult Success(CredentialType type) => new() { IsSuccess = true, Type = type }; - public static ChangeCredentialResult Fail(string error) + public static CredentialChangeResult Fail(string error) => new() { IsSuccess = false, diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialsGetResult.cs similarity index 80% rename from src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs rename to src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialsGetResult.cs index 8a8a5315..5b9fe353 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialsGetResult.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Credentials.Contracts; -public sealed record GetCredentialsResult +public sealed record CredentialsGetResult { public IReadOnlyCollection Credentials { get; init; } = Array.Empty(); } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 65a462aa..c41aff30 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -60,11 +60,11 @@ public CredentialManagementService( _clock = clock; } - public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) + public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -86,17 +86,17 @@ public async Task GetAllAsync(AccessContext context, Cance }) .ToArray(); - return new GetCredentialsResult { Credentials = dtos }; + return new CredentialsGetResult { Credentials = dtos }; }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) + public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -115,17 +115,17 @@ public async Task AddAsync(AccessContext context, AddCreden var store = _credentialsFactory.Create(context.ResourceTenant); await store.AddAsync(credential, innerCt); - return AddCredentialResult.Success(credential.Id, credential.Type); + return CredentialAddResult.Success(credential.Id, credential.Type); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) + public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { var subjectUser = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -165,7 +165,7 @@ await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, con : CredentialChangeReason.AdminChange, context.ActorChainId, now, innerCt); - return ChangeCredentialResult.Success(pwd.Type); + return CredentialChangeResult.Success(pwd.Type); }); return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); @@ -199,11 +199,11 @@ public async Task RevokeAsync(AccessContext context, Rev return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } - public async Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default) + public async Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var cmd = new AccessCommand(async innerCt => + var cmd = new AccessCommand(async innerCt => { if (_resetNotifier is NotConfiguredCredentialResetNotifier) @@ -224,7 +224,7 @@ public async Task BeginResetAsync(AccessContext cont var validity = _options.ResetCredential.TokenValidity; var expiresAt = now.Add(validity); - var result = new BeginCredentialResetResult + var result = new CredentialBeginResetResult { ExpiresAt = expiresAt }; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs index 4816c180..ee0f7642 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -5,15 +5,15 @@ namespace CodeBeam.UltimateAuth.Credentials.Reference; public interface ICredentialManagementService { - Task GetAllAsync(AccessContext context, CancellationToken ct = default); + Task GetAllAsync(AccessContext context, CancellationToken ct = default); - Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); + Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); - Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default); + Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default); Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); - Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default); + Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default); Task CompleteResetAsync(AccessContext context, CompleteResetCredentialRequest request, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs index ec7763df..11044b73 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs @@ -4,5 +4,5 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record LogoutOtherDevicesRequest { - public required SessionChainId CurrentChainId { get; init; } + public required SessionChainId ExceptChainId { get; init; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserLifecycleQuery.cs similarity index 80% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserLifecycleQuery.cs index d308ff45..bd96e35e 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserLifecycleQuery.cs @@ -1,6 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference; +namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserLifecycleQuery : PageRequest { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserProfileQuery.cs similarity index 67% rename from src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserProfileQuery.cs index 6e122100..e3748ee6 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserProfileQuery.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Core.Contracts; -using CodeBeam.UltimateAuth.Users.Contracts; -namespace CodeBeam.UltimateAuth.Users.Reference; +namespace CodeBeam.UltimateAuth.Users.Contracts; public sealed record UserProfileQuery : PageRequest { diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs deleted file mode 100644 index 85bee388..00000000 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -//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 -//} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistenceResult.cs similarity index 80% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistenceResult.cs index ec234ff4..061c7ddd 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistenceResult.cs @@ -2,7 +2,7 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed record IdentifierExistenceResult( +public sealed record UserIdentifierExistenceResult( bool Exists, UserKey? OwnerUserKey = null, Guid? OwnerIdentifierId = null, diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistsResponse.cs similarity index 65% rename from src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs rename to src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistsResponse.cs index 0d2150d1..2a419067 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserIdentifierExistsResponse.cs @@ -1,6 +1,6 @@ namespace CodeBeam.UltimateAuth.Users.Contracts; -public sealed class IdentifierExistsResponse +public sealed class UserIdentifierExistsResponse { public bool Exists { get; set; } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index 2974b556..6d20394d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -35,7 +35,7 @@ public async Task ExistsAsync(Guid key, CancellationToken ct = default) ct); } - public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -74,9 +74,9 @@ public async Task ExistsAsync(IdentifierExistenceQuer .FirstOrDefaultAsync(ct); if (match is null) - return new IdentifierExistenceResult(false); + return new UserIdentifierExistenceResult(false); - return new IdentifierExistenceResult( + return new UserIdentifierExistenceResult( true, match.UserKey, match.Id, diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs index 31ce250c..5cb276b8 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.EntityFrameworkCore; diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs index 6c26db5c..beb61f6b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -20,7 +20,7 @@ public InMemoryUserIdentifierStore(TenantExecutionContext tenant, InMemoryAtomic _pagination = pagination; } - public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -50,10 +50,10 @@ public Task ExistsAsync(IdentifierExistenceQuery quer var match = candidates.FirstOrDefault(); if (match is null) - return Task.FromResult(new IdentifierExistenceResult(false)); + return Task.FromResult(new UserIdentifierExistenceResult(false)); return Task.FromResult( - new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); + new UserIdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); } public Task GetAsync(UserIdentifierType type, string normalizedValue, CancellationToken ct = default) diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs index 5bf7253a..a8cd29cf 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; namespace CodeBeam.UltimateAuth.Users.InMemory; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 52a9e2a3..9d14cd2d 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -366,7 +366,7 @@ public async Task IdentifierExistsSelfAsync(HttpContext ctx) IdentifierExistenceScope.WithinUser, ctx.RequestAborted); - return Results.Ok(new IdentifierExistsResponse + return Results.Ok(new UserIdentifierExistsResponse { Exists = exists }); @@ -393,7 +393,7 @@ public async Task IdentifierExistsAdminAsync(UserKey userKey, HttpConte IdentifierExistenceScope.TenantAny, ctx.RequestAborted); - return Results.Ok(new IdentifierExistsResponse + return Results.Ok(new UserIdentifierExistsResponse { Exists = exists }); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs index 7778eed5..ea6b2086 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserIdentifierAvailabilityService.cs @@ -3,9 +3,10 @@ using CodeBeam.UltimateAuth.Server.Options; using CodeBeam.UltimateAuth.Server.Services; using CodeBeam.UltimateAuth.Users.Contracts; -using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.Options; +namespace CodeBeam.UltimateAuth.Users.Reference; + public sealed class UserIdentifierAvailabilityService : IUserIdentifierAvailabilityService { private readonly IUserIdentifierValidator _validator; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs index 04e004ad..722af92a 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Users.Reference; public interface IUserIdentifierStore : IVersionedStore { - Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); + Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs index c687f6da..4d44624f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; namespace CodeBeam.UltimateAuth.Users.Reference; diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs index dc634806..64dc47a1 100644 --- a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserIdentifierVerifier.cs @@ -2,6 +2,8 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Users.Contracts; +namespace CodeBeam.UltimateAuth.Users; + /// /// Validates identifier ownership proof on the server. /// diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs index dfd5b153..c243f63e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/FailingUserIdentifierStore.cs @@ -58,7 +58,7 @@ public Task DeleteAsync( now, ct); - public Task ExistsAsync( + public Task ExistsAsync( IdentifierExistenceQuery query, CancellationToken ct = default) => _inner.ExistsAsync(query, ct); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs index 4b9bfbe3..4e8d0096 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs @@ -429,7 +429,7 @@ await GrantAsync(factory, admin, response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(); result.Should().NotBeNull(); result!.UserKey.Should().Be(target.UserKey); @@ -456,7 +456,7 @@ public async Task GetMyRoles_ShouldReturnOwnRole() response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(); result.Should().NotBeNull(); result!.UserKey.Should().Be(user.UserKey); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs index b71421cd..ab9450e4 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -167,7 +167,7 @@ await factory.GrantPermissionsAsync( response.StatusCode.Should().Be(HttpStatusCode.OK); var result = await response.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(); result.Should().NotBeNull(); result!.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); @@ -258,7 +258,7 @@ await factory.GrantPermissionsAsync( begin.StatusCode.Should().Be(HttpStatusCode.OK); var beginResult = await begin.Content - .ReadFromJsonAsync(); + .ReadFromJsonAsync(); beginResult.Should().NotBeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs index 82671d49..1d2376a1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/FailingUserIdentifierStore.cs @@ -59,7 +59,7 @@ public Task DeleteAsync( now, ct); - public Task ExistsAsync( + public Task ExistsAsync( IdentifierExistenceQuery query, CancellationToken ct = default) => _inner.ExistsAsync(query, ct); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs index bd77b124..61deb287 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs @@ -590,7 +590,7 @@ await _factory.GrantPermissionsAsync( $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { - CurrentChainId = currentChain.ChainId + ExceptChainId = currentChain.ChainId }); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -658,7 +658,7 @@ await LoginAsync( $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { - CurrentChainId = currentChain.ChainId + ExceptChainId = currentChain.ChainId }); response.StatusCode.Should() @@ -758,7 +758,7 @@ await LoginAsync( $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { - CurrentChainId = foreignChain.ChainId + ExceptChainId = foreignChain.ChainId }); response.StatusCode.Should().BeOneOf( @@ -843,7 +843,7 @@ await LoginAsync( $"/auth/admin/users/{target.UserKey.Value}/logout-others", new LogoutOtherDevicesRequest { - CurrentChainId = currentChain.ChainId + ExceptChainId = currentChain.ChainId }); response.StatusCode.Should() @@ -931,7 +931,7 @@ await LoginAsync( var request = new LogoutOtherDevicesRequest { - CurrentChainId = preservedChain.ChainId + ExceptChainId = preservedChain.ChainId }; var url = diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs index 1e353d3d..92a5d23b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs @@ -102,7 +102,7 @@ public async Task AdminBeginReset_ShouldReturnTypedJson_WithoutProof() await AuthenticateAsync(client, admin); using var response = await client.PostAsJsonAsync( $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(target)); - var result = await AssertTypedSuccessAsync(response); + var result = await AssertTypedSuccessAsync(response); result.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); var body = await response.Content.ReadAsStringAsync(); var notification = notifier.Notifications.Should().ContainSingle(x => x.UserKey == target.UserKey).Subject; @@ -122,7 +122,7 @@ await factory.GrantPermissionsAsync(admin.UserKey, await AuthenticateAsync(client, admin); var url = $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; using var begin = await client.PostAsJsonAsync($"{url}/begin", BeginResetRequest(target)); - await AssertTypedSuccessAsync(begin); + await AssertTypedSuccessAsync(begin); var token = notifier.Notifications.Single(x => x.UserKey == target.UserKey).Token; var newSecret = $"Changed-{Guid.NewGuid():N}!"; using var complete = await client.PostAsJsonAsync($"{url}/complete", diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs index b8896209..287c15a9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserIdentifierTests.cs @@ -1,6 +1,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users; using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs index 8c5e5782..28bd0552 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs @@ -534,7 +534,7 @@ public async Task DeleteRoleAsync_WhenAuthenticated_ForwardsDeleteMode() roleId.ToString(), accessContext); - var expected = new DeleteRoleResult + var expected = new RoleDeleteResult { RoleId = roleId, Mode = DeleteMode.Hard, diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs index 6c01265b..dadb2d58 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs @@ -466,9 +466,9 @@ public Fixture() AccessOrchestrator .Setup(x => x.ExecuteAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), It.IsAny())) - .Returns, CancellationToken>( + .Returns, CancellationToken>( async (_, command, ct) => await command.ExecuteAsync(ct)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs index 00113329..fa697b12 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs @@ -117,7 +117,7 @@ public async Task GetMyRoles_Should_Call_Correct_Endpoint_And_Return_Data() { var userKey = UserKey.FromString("user-1"); - var response = new UserRolesResponse + var response = new UserRolesGetResult { UserKey = userKey, Roles = new PagedResult( @@ -158,7 +158,7 @@ public async Task GetUserRoles_Should_Call_Admin_Endpoint() { var userKey = UserKey.FromString("user-1"); - var response = new UserRolesResponse + var response = new UserRolesGetResult { UserKey = userKey, Roles = new PagedResult( @@ -262,7 +262,7 @@ public async Task SetRolePermissions_Should_Publish_Event_On_Success() public async Task DeleteRole_Should_Publish_Event_On_Success() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new DeleteRoleResult())); + .ReturnsAsync(SuccessJson(new RoleDeleteResult())); var client = CreateClient(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs index 51a26819..e4997a11 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs @@ -15,7 +15,7 @@ public class UAuthClientCredentialTests : UAuthClientTestBase public async Task AddMy_Should_Call_Correct_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new AddCredentialResult())); + .ReturnsAsync(SuccessJson(new CredentialAddResult())); var client = CreateCredentialClient(); await client.Credentials.AddMyAsync(new AddCredentialRequest() { Secret = "uauth" }); @@ -26,7 +26,7 @@ public async Task AddMy_Should_Call_Correct_Endpoint() public async Task ChangeMy_Should_Publish_Event_On_Success() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new ChangeCredentialResult())); + .ReturnsAsync(SuccessJson(new CredentialChangeResult())); var client = CreateCredentialClient(); await client.Credentials.ChangeMyAsync(new ChangeCredentialRequest() { NewSecret = "uauth" }); @@ -59,7 +59,7 @@ public async Task RevokeMy_Should_Publish_Event() public async Task AddUser_Should_Call_Admin_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new AddCredentialResult())); + .ReturnsAsync(SuccessJson(new CredentialAddResult())); var client = CreateCredentialClient(); var userKey = UserKey.FromString("user-1"); @@ -111,10 +111,10 @@ public async Task DeleteUser_Should_Call_Delete_Endpoint() public async Task BeginResetMy_Should_Call_Correct_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new BeginCredentialResetResult())); + .ReturnsAsync(SuccessJson(new CredentialBeginResetResult())); var client = CreateCredentialClient(); - await client.Credentials.BeginResetMyAsync(new BeginResetCredentialRequest() { Identifier = "user1" }); + await client.Credentials.BeginResetAsync(new BeginResetCredentialRequest() { Identifier = "user1" }); Request.Verify(x => x.SendJsonAsync("/auth/me/credentials/reset/begin", It.IsAny()), Times.Once); } @@ -125,7 +125,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", Identifier = "user1", ResetToken = "fake_token" }); + await client.Credentials.CompleteResetAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth", Identifier = "user1", ResetToken = "fake_token" }); Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.CredentialsChanged)), Times.Once); } @@ -136,7 +136,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", Identifier = "user1", ResetToken = "fake_token" }); + await client.Credentials.CompleteResetAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth", Identifier = "user1", ResetToken = "fake_token" }); Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); } @@ -144,7 +144,7 @@ public async Task CompleteResetMy_Should_NOT_Publish_Event_On_Failure() public async Task BeginResetUser_Should_Call_Admin_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new BeginCredentialResetResult())); + .ReturnsAsync(SuccessJson(new CredentialBeginResetResult())); var client = CreateCredentialClient(); var userKey = UserKey.FromString("user-1"); @@ -170,7 +170,7 @@ public async Task CompleteResetUser_Should_NOT_Publish_Event() public async Task ChangeUser_Should_Call_Admin_Endpoint() { Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(SuccessJson(new ChangeCredentialResult())); + .ReturnsAsync(SuccessJson(new CredentialChangeResult())); var client = CreateCredentialClient(); var userKey = UserKey.FromString("user-1"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs index f15fb9d1..6acbe060 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientEventConsistencyTests.cs @@ -115,7 +115,7 @@ public async Task Credential_Should_Not_Publish_When_Response_Is_Invalid(string { "Change" => () => client.ChangeMyAsync(null!), "Revoke" => () => client.RevokeMyAsync(null!), - "CompleteReset" => () => client.CompleteResetMyAsync(null!), + "CompleteReset" => () => client.CompleteResetAsync(null!), _ => throw new ArgumentOutOfRangeException(nameof(operation)) }; @@ -137,7 +137,7 @@ public async Task Typed_Credential_Response_Should_Not_Publish_When_Body_Missing Func act = operation switch { "Change" => () => client.ChangeMyAsync(null!), - "CompleteReset" => () => client.CompleteResetMyAsync(null!), + "CompleteReset" => () => client.CompleteResetAsync(null!), _ => throw new ArgumentOutOfRangeException(nameof(operation)) }; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index 830b225b..6ac2c9b0 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -777,7 +777,7 @@ public async Task LogoutUserOtherDevices_Should_Call_Admin_Endpoint() var client = CreateFlowClient(mock); var userKey = UserKey.FromString("user-1"); - await client.LogoutUserOtherDevicesAsync(userKey, new LogoutOtherDevicesRequest() { CurrentChainId = SessionChainId.New() }); + await client.LogoutUserOtherDevicesAsync(userKey, new LogoutOtherDevicesRequest() { ExceptChainId = SessionChainId.New() }); mock.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/logout-others", It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index 47d17807..221e2435 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -45,7 +45,7 @@ public async Task GetAllAsync_WhenAuthenticated_UsesListSelfAndReturnsOk() f.UserKey.Value, access); - var serviceResult = new GetCredentialsResult(); + var serviceResult = new CredentialsGetResult(); f.Credentials .Setup(x => x.GetAllAsync( @@ -56,7 +56,7 @@ public async Task GetAllAsync_WhenAuthenticated_UsesListSelfAndReturnsOk() var result = await f.Sut.GetAllAsync(f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(serviceResult); @@ -100,7 +100,7 @@ public async Task AddAsync_WhenAuthenticated_ForwardsRequestUsingAddSelf() f.UserKey.Value, access); - var serviceResult = AddCredentialResult.Success( + var serviceResult = CredentialAddResult.Success( Guid.NewGuid(), CredentialType.Password); @@ -115,7 +115,7 @@ public async Task AddAsync_WhenAuthenticated_ForwardsRequestUsingAddSelf() var result = await f.Sut.AddAsync(f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(serviceResult); @@ -162,7 +162,7 @@ public async Task ChangeSecretAsync_WhenAuthenticated_ForwardsRequestUsingChange access); var serviceResult = - ChangeCredentialResult.Success(CredentialType.Password); + CredentialChangeResult.Success(CredentialType.Password); f.Credentials .Setup(x => x.ChangeSecretAsync( @@ -176,7 +176,7 @@ public async Task ChangeSecretAsync_WhenAuthenticated_ForwardsRequestUsingChange var result = await f.Sut.ChangeSecretAsync(f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(serviceResult); @@ -242,7 +242,7 @@ public async Task BeginResetAsync_WhenUnauthenticated_IsAllowed() identifier, access); - var serviceResult = new BeginCredentialResetResult(); + var serviceResult = new CredentialBeginResetResult(); f.Credentials .Setup(x => x.BeginResetAsync( @@ -257,7 +257,7 @@ public async Task BeginResetAsync_WhenUnauthenticated_IsAllowed() var result = await f.Sut.BeginResetAsync(f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(serviceResult); @@ -343,7 +343,7 @@ public async Task GetAllAdminAsync_WhenAuthenticated_UsesListAdminForTargetUser( target.Value, access); - var serviceResult = new GetCredentialsResult(); + var serviceResult = new CredentialsGetResult(); f.Credentials .Setup(x => x.GetAllAsync( @@ -356,7 +356,7 @@ public async Task GetAllAdminAsync_WhenAuthenticated_UsesListAdminForTargetUser( f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(serviceResult); @@ -386,7 +386,7 @@ public async Task AddAdminAsync_WhenAuthenticated_UsesAddAdminForTargetUser() target.Value, access); - var serviceResult = AddCredentialResult.Success( + var serviceResult = CredentialAddResult.Success( Guid.NewGuid(), CredentialType.Password); @@ -402,7 +402,7 @@ public async Task AddAdminAsync_WhenAuthenticated_UsesAddAdminForTargetUser() target, f.HttpContext); - result.Should().BeOfType>(); + result.Should().BeOfType>(); } // ========================================================= @@ -445,7 +445,7 @@ public async Task ChangeSecretAdminAsync_WhenAuthenticated_UsesChangeAdminForTar access); var serviceResult = - ChangeCredentialResult.Success(CredentialType.Password); + CredentialChangeResult.Success(CredentialType.Password); f.Credentials .Setup(x => x.ChangeSecretAsync( @@ -459,7 +459,7 @@ public async Task ChangeSecretAdminAsync_WhenAuthenticated_UsesChangeAdminForTar target, f.HttpContext); - result.Should().BeOfType>(); + result.Should().BeOfType>(); } // ========================================================= @@ -574,7 +574,7 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc target.Value, access); - var expected = new BeginCredentialResetResult(); + var expected = new CredentialBeginResetResult(); f.Credentials .Setup(x => x.BeginResetAsync( @@ -588,7 +588,7 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value.Should().BeSameAs(expected); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index 891e3981..356222f5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -1002,30 +1002,30 @@ public Fixture() AccessOrchestrator .Setup(x => x.ExecuteAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), It.IsAny())) .Returns, + AccessCommand, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); AccessOrchestrator .Setup(x => x.ExecuteAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), It.IsAny())) .Returns, + AccessCommand, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); AccessOrchestrator .Setup(x => x.ExecuteAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), It.IsAny())) .Returns, + AccessCommand, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); @@ -1043,10 +1043,10 @@ public Fixture() AccessOrchestrator .Setup(x => x.ExecuteAsync( It.IsAny(), - It.IsAny>(), + It.IsAny>(), It.IsAny())) .Returns, + AccessCommand, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs index cf6aad14..b93f2618 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs @@ -430,7 +430,7 @@ public async Task LogoutOthersAdminAsync_UsesRequestedCurrentChain() fixture.HttpContext, new LogoutOtherDevicesRequest { - CurrentChainId = currentChainId + ExceptChainId = currentChainId }); var access = TestAccessContext.ForUser( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs index dd4e823f..52fcbc33 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using FluentAssertions; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 5868c2d6..c8481e93 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1151,7 +1151,7 @@ public async Task UpdateUserIdentifierAsync_ValidatesNewValue_NotExistingValue() q.UserKey == null && q.ExcludeIdentifierId == identifier.Id), It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult( + .ReturnsAsync(new UserIdentifierExistenceResult( Exists: false)); f.IdentifierValidator @@ -1188,7 +1188,7 @@ public async Task UpdateUserIdentifierAsync_ValidatesNewValue_NotExistingValue() q.UserKey == identifier.UserKey && q.ExcludeIdentifierId == identifier.Id), It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult( + .ReturnsAsync(new UserIdentifierExistenceResult( Exists: false)); f.IdentifierStore @@ -1690,7 +1690,7 @@ public async Task UserIdentifierExistsAsync_TenantScope_DoesNotPassUserKey( q.Scope == scope && q.UserKey == null), It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult(Exists: true)); + .ReturnsAsync(new UserIdentifierExistenceResult(Exists: true)); var result = await f.Sut.UserIdentifierExistsAsync( context, @@ -1726,7 +1726,7 @@ public async Task UserIdentifierExistsAsync_WithinUser_PassesTargetUserKey() q.Scope == IdentifierExistenceScope.WithinUser && q.UserKey == target), It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult(Exists: true)); + .ReturnsAsync(new UserIdentifierExistenceResult(Exists: true)); var result = await f.Sut.UserIdentifierExistsAsync( context, @@ -1757,7 +1757,7 @@ public async Task UserIdentifierExistsAsync_WhenStoreReportsMissing_ReturnsFalse q.Scope == IdentifierExistenceScope.TenantPrimaryOnly && q.UserKey == null), It.IsAny())) - .ReturnsAsync(new IdentifierExistenceResult(Exists: false)); + .ReturnsAsync(new UserIdentifierExistenceResult(Exists: false)); var result = await f.Sut.UserIdentifierExistsAsync( context, UserIdentifierType.Username, "missing"); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs index 69e65095..31577bbc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -759,7 +759,7 @@ public async Task IdentifierExistsSelfAsync_UsesWithinUserScope() await f.Sut.IdentifierExistsSelfAsync(f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value!.Exists.Should().BeTrue(); @@ -805,7 +805,7 @@ public async Task IdentifierExistsAdminAsync_UsesTenantAnyScope() f.HttpContext); var ok = result.Should() - .BeOfType>() + .BeOfType>() .Subject; ok.Value!.Exists.Should().BeTrue(); From 73a38cca24583de6f36592cf382b1977d06ade0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:42:00 +0300 Subject: [PATCH 16/20] Fix Several Refresh Issues (#74) --- .../Validators/UAuthTokenOptionsValidator.cs | 4 +- .../Issuers/UAuthTokenIssuer.cs | 2 +- .../Options/UAuthServerOptions.cs | 1 + .../UAuthServerSessionOptionsValidator.cs | 10 ++-- .../UAuthServerTokenOptionsValidator.cs | 4 +- .../Services/RefreshTokenRotationService.cs | 8 +++ .../RefreshTokenRotationServiceTests.cs | 50 +++++++++++++++++++ .../Server/ServerOptionsValidatorTests.cs | 32 +++++++++++- .../Tokens/UAuthTokenIssuerTests.cs | 46 +++++++++++++---- 9 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 9d774c7d..8b211b4a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -34,8 +34,8 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) if (options.IssueOpaque) { - if (options.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + if (options.OpaqueIdBytes < 24) + errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy)."); if (options.OpaqueIdBytes > 128) errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 36bdbd27..23223858 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -52,7 +52,7 @@ UAuthMode.SemiHybrid or public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { - if (flow.EffectiveMode == UAuthMode.PureOpaque) + if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh) return null; if (context.SessionId is not AuthSessionId sessionId) diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 34af5b93..1c04962c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -133,6 +133,7 @@ public sealed class UAuthServerOptions /// public Action? OnConfigureEndpoints { get; set; } + // TODO: Add same startup validations. Mode-based options should not pass through the option invariant. internal Dictionary> ModeConfigurations { get; set; } = new(); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs index b2d9970b..8b7baf0b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -21,13 +21,17 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) errors.Add("Session.IdleTimeout cannot be negative."); - if (options.Session.IdleTimeout is { } idle && - options.Session.TouchInterval is { } touch && - idle > TimeSpan.Zero && touch >= idle) + if (options.Session.IdleTimeout is { } idle && options.Session.TouchInterval is { } touch && idle > TimeSpan.Zero && touch >= idle) { errors.Add("Session.TouchInterval must be less than Session.IdleTimeout."); } + if (session.SlidingExpiration && session.TouchInterval is { } touchInterval && touchInterval >= session.Lifetime) + { + errors.Add( + "Session.TouchInterval must be less than Session.Lifetime when SlidingExpiration is enabled."); + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs index f6107b7d..fe445470 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs @@ -40,8 +40,8 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (tokens.IssueOpaque) { - if (tokens.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + if (tokens.OpaqueIdBytes < 24) + errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy)."); if (tokens.OpaqueIdBytes > 128) errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 56471b93..ae1fb1a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -28,6 +28,14 @@ public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTok // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) { + if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh) + { + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; + } + var validation = await _validator.ValidateAsync( new RefreshTokenValidationContext { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 5442498c..fa7131c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -620,6 +620,56 @@ await act.Should() Times.Never); } + [Fact] + public async Task RotateAsync_WhenIssueRefreshDisabled_ShouldFailWithoutValidationOrMutation() + { + var validator = new Mock(); + var storeFactory = new Mock(); + var issuer = new Mock(); + var claimsProvider = new Mock(); + + var flow = CreateFlow(); + flow.Options.Token.IssueRefresh = false; + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object, + claimsProvider.Object); + + var result = await sut.RotateAsync( + flow, + CreateContext()); + + result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); + + validator.Verify( + x => x.ValidateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + storeFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + + issuer.Verify( + x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + issuer.Verify( + x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + private static Mock CreateValidator(RefreshTokenValidationResult result) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 781d89f2..dd98580b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -708,7 +708,7 @@ public void Token_opaque_entropy_outside_limits_fails(int bytes) => }, "OpaqueIdBytes"); [Theory] - [InlineData(16)] + [InlineData(24)] [InlineData(128)] public void Token_opaque_entropy_boundary_values_pass(int bytes) => ShouldAccept(o => @@ -928,6 +928,36 @@ public void Server_null_session_max_lifetime_passes_under_current_rules() => }); + [Theory] + [InlineData(1, 5)] + [InlineData(5, 5)] + public void Session_sliding_expiration_touch_interval_greater_than_or_equal_to_lifetime_fails( + int lifetimeMinutes, + int touchIntervalMinutes) => + ShouldReject(o => + { + o.Session.SlidingExpiration = true; + o.Session.Lifetime = TimeSpan.FromMinutes(lifetimeMinutes); + o.Session.TouchInterval = TimeSpan.FromMinutes(touchIntervalMinutes); + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; + }, "Session.TouchInterval must be less than Session.Lifetime"); + + + [Fact] + public void Session_sliding_expiration_touch_interval_less_than_lifetime_passes() => + ShouldAccept(o => + { + o.Session.SlidingExpiration = true; + o.Session.Lifetime = TimeSpan.FromMinutes(5); + o.Session.TouchInterval = TimeSpan.FromMinutes(1); + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; + }); + + + + private static void ShouldReject(Action configure, string expected) where TValidator : class, IValidateOptions, new() { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs index 6b2ff195..0f094f11 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -1101,6 +1101,37 @@ public async Task IssueTokens_ShouldUseEffectiveLifetimes() refresh!.ExpiresAt.Should().Be(Now.AddMinutes(30)); } + [Theory] + [InlineData(UAuthMode.Hybrid)] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public async Task IssueRefreshTokenAsync_WhenIssueRefreshDisabled_ShouldReturnNull(UAuthMode mode) + { + var fixture = CreateFixture(); + var flow = CreateFlow(mode); + + flow.Options.Token.IssueRefresh = false; + + var result = await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: null), + RefreshTokenPersistence.Persist); + + result.Should().BeNull(); + + fixture.OpaqueGenerator.Verify( + x => x.Generate(), + Times.Never); + + fixture.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + // ===================================================================== // Context helpers // ===================================================================== @@ -1112,7 +1143,8 @@ private static AuthFlowContext CreateFlow( string? issuer = null, string? audience = null, string? keyId = null, - bool addJwtIdClaim = true) + bool addJwtIdClaim = true, + bool issueRefresh = true) { /* * Adapt only this helper to the existing AuthFlowContext builder/helper @@ -1124,15 +1156,11 @@ private static AuthFlowContext CreateFlow( * - OriginalOptions.Token values below */ - var flow = AuthFlowTestFactory.New( - mode: mode, - tenant: Tenant); - - flow.Options.Token.AccessTokenLifetime = - accessTokenLifetime ?? TimeSpan.FromMinutes(15); + var flow = AuthFlowTestFactory.New(mode: mode, tenant: Tenant); - flow.Options.Token.RefreshTokenLifetime = - refreshTokenLifetime ?? TimeSpan.FromDays(30); + flow.Options.Token.AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15); + flow.Options.Token.RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30); + flow.Options.Token.IssueRefresh = issueRefresh; if (issuer is not null) flow.Options.Token.Issuer = issuer; From 66f28b4ecaabfbb40360f2e550accfc51cb50289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:49:31 +0300 Subject: [PATCH 17/20] Enhance CI triggers for release branches and tags Updated CI workflow to trigger on release branches and version tags. --- .github/workflows/ultimateauth-ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml index 021000ae..372f254b 100644 --- a/.github/workflows/ultimateauth-ci.yml +++ b/.github/workflows/ultimateauth-ci.yml @@ -2,9 +2,15 @@ name: UltimateAuth CI on: push: - branches: [ dev ] + branches: + - dev + - 'release/**' + tags: + - 'v*' pull_request: - branches: [ dev ] + branches: + - dev + - 'release/**' workflow_dispatch: jobs: From f636793afbd858ccd243132a9af8b8bb204b6fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:02:35 +0300 Subject: [PATCH 18/20] Add verification steps for NuGet publishing workflow --- .github/workflows/nuget-publish.yml | 54 ++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 4857811b..7fc7768e 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -13,6 +13,14 @@ jobs: environment: production steps: + - name: Verify release tag + shell: bash + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::NuGet publishing requires a stable release tag (vX.Y.Z)." + exit 1 + fi + - name: Checkout uses: actions/checkout@v4 @@ -24,6 +32,20 @@ jobs: 9.0.x 10.0.x + - name: Verify package version + shell: bash + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + + PROJECT_VERSION=$(dotnet msbuild \ + -getProperty:Version \ + src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj) + + if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then + echo "::error::Tag version ($TAG_VERSION) does not match package version ($PROJECT_VERSION)." + exit 1 + fi + - name: Restore run: dotnet restore @@ -38,6 +60,28 @@ jobs: /p:IncludeSymbols=true \ /p:SymbolPackageFormat=snupkg + - name: Verify packages + shell: bash + run: | + shopt -s nullglob + packages=(./nupkgs/*.nupkg) + + if (( ${#packages[@]} == 0 )); then + echo "::error::No NuGet packages were generated." + exit 1 + fi + + VERSION="${GITHUB_REF_NAME#v}" + + for f in "${packages[@]}"; do + if [[ "$f" != *".$VERSION.nupkg" ]]; then + echo "::error::Unexpected package version: $f" + exit 1 + fi + done + + echo "Validated ${#packages[@]} packages for version $VERSION" + - name: NuGet login (Trusted Publishing) uses: NuGet/login@v1 id: login @@ -48,18 +92,18 @@ jobs: run: ls -la ./nupkgs - name: Publish packages + shell: bash run: | - echo "🚀 Publishing..." + set -euo pipefail for f in ./nupkgs/*.nupkg; do - echo "📦 $f" + echo "Publishing: $f" dotnet nuget push "$f" \ --api-key ${{ steps.login.outputs.NUGET_API_KEY }} \ --source https://api.nuget.org/v3/index.json \ - --skip-duplicate \ - || echo "❌ Failed: $f" + --skip-duplicate done - name: Done - run: echo "✅ Publish process completed" + run: echo "✅ NuGet publishing completed succesfully." From 9d5acfc6fc890cc7bb35cacbdf4cfdac86704303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:40:15 +0300 Subject: [PATCH 19/20] Update v 0.1.0 (#75) --- Directory.Build.props | 2 +- README.md | 4 +- docs/content/auth-flows/refresh-flow.md | 227 ++++++++++++------------ 3 files changed, 113 insertions(+), 120 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 982a9ce8..4f6fa251 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.0-rc.3 + 0.1.0 $(NoWarn);1591 CodeBeam diff --git a/README.md b/README.md index a9c2352d..c1f1e024 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | | First Preview | 0.1.0-preview | "Stable" Preview Core | ✅ Completed | 07.04.2026 | -| First Release* | 0.1.0 | Documented & Quality Tested | ✅ Completed | 12.10.2026 | +| First Release* | 0.1.0 | Documented & Quality Tested (Client Public API Locked) | ✅ Completed | 12.10.2026 | | Product Expansion | 0.2.0 | Full Auth Modes | 🟡 In Progress | Q4 2026 | | Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟡 In Progress | Q4 2026 | | Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | 🔜 Planned | Q1 2027 | @@ -43,7 +43,7 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Ecosystem Expansion | 0.8.0 | Migration tools | 🔜 Planned | Q2 2027 | | v1.0 | 1.0.0 | Locked API, align with .NET 11 | 🔜 Planned | Q2 2027 | -*v 0.1.0 already provides a skeleton of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. +*v 0.1.0 already provides a skeleton and extensibility points of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. > The project roadmap is actively maintained as a GitHub issue: diff --git a/docs/content/auth-flows/refresh-flow.md b/docs/content/auth-flows/refresh-flow.md index 9ff51683..cd1abd0c 100644 --- a/docs/content/auth-flows/refresh-flow.md +++ b/docs/content/auth-flows/refresh-flow.md @@ -4,178 +4,171 @@ order: 3 group: auth-flows --- +# Refresh Flow -# 🔄 Refresh Flow -The refresh flow in UltimateAuth is not a single fixed operation. +Refresh is how an existing authentication context continues without asking the user to authenticate again. In UltimateAuth, it is not synonymous with issuing a new access token: the operation depends on the active authentication mode and on whether the context is represented by a server-side session, refresh credentials, or both. -👉 It is a **mode-dependent continuation strategy**. +A successful refresh preserves the authenticated identity and its continuity. It does **not** prove the user's credentials again, create a new login, or necessarily extend every lifetime associated with the authentication. -## 🧠 What is Refresh? -In traditional systems: +## The model: continuity, not a second login -- Refresh = get a new access token +UltimateAuth distinguishes three related operations: -In UltimateAuth: +- **Authentication** establishes a new authenticated context after the required proof is accepted. +- **Refresh** attempts to continue an already established context, subject to its current validity and security policies. +- **Reauthentication** establishes trust again when continuation is unavailable or no longer permitted. -👉 Refresh continues an existing authentication state +This distinction matters for both security and client behavior. A refresh request can fail because a session expired, a refresh credential is invalid or consumed, the device context does not match, or the underlying chain is no longer usable. The client should treat an unsuccessful refresh as a need to authenticate again, not as an invitation to retry the same credential indefinitely. -> Refresh is not re-authentication -> → It is session continuation +## Mode-dependent strategies -## 🔀 Two Refresh Strategies -UltimateAuth supports two fundamentally different refresh behaviors: +UltimateAuth implements refresh through two building blocks: **session touch** and **refresh-token rotation**. A mode selects how they are combined. -### 🔹 Session Touch (Stateful) -Used in **PureOpaque mode** +| Authentication mode | Required refresh input | Strategy | Result | +| --- | --- | --- | --- | +| `PureOpaque` | Session ID | Validate session; touch when policy requires | Continue the existing session; no new access or refresh token | +| `Hybrid` | Session ID **and** refresh token | Validate and conditionally touch session; rotate refresh token | Continue session and issue replacement tokens | +| `SemiHybrid` | Session ID **and** refresh token | Validate session; rotate refresh token | Issue replacement tokens without a session touch | +| `PureJwt` | Refresh token | Validate and rotate refresh token | Issue replacement tokens; no session touch in the flow | -- No tokens involved -- No new session created -- Existing session is extended +**Current support status:** `PureOpaque` and `Hybrid` are the supported modes for the initial release. The `SemiHybrid` and `PureJwt` branches are present in the refresh implementation but should not be presented as completed, generally supported configurations in v0.1.0. -``` -Session → Validate → Touch → Continue +The mode is resolved from the effective authentication context on the server. Clients do not select a refresh algorithm independently. + +## Session touch + +A session touch is a controlled update to existing server-side authentication state. It does not issue a replacement credential or create a new session. + +```text +Session ID + → Validate session, chain and device context + → Recheck active state and chain/session relationship + → Touch if policy requires + → Continue with the same session ID ``` -👉 This updates activity without changing identity +In `PureOpaque`, the refresh flow requires a session ID. The session validator checks the session against the current tenant, device context and session options. The touch service then reloads the session and chain from the store and checks that they are still active and consistent, including the chain's active session, tenant, user and chain identifiers. -### 🔹 Token Rotation (Stateless / Hybrid) -Used in: +Touch behavior is governed by the effective session options: -- Hybrid -- SemiHybrid -- PureJwt +- `TouchInterval` avoids writing activity state on every request. If the interval has not elapsed, the operation may succeed as **NoOp**. +- `TouchMode` is `IfNeeded` by default; `Force` requests a touch even when the normal interval has not elapsed. +- `SlidingExpiration` allows a touch to extend the session expiration using the configured `Lifetime`. +- An absolute chain expiration remains an upper bound; sliding expiration cannot extend the session beyond it. +- `IdleTimeout` participates in chain-state validation. An idle or otherwise inactive chain cannot be revived merely by touching it. -- Refresh token is validated -- Old token is revoked -- New tokens are issued +A successful **NoOp** is not a failed refresh: the existing state was valid, but no persistence update was necessary. Likewise, a touch is not an unconditional promise to extend the session's expiry; that depends on policy and the absolute limit. +## Refresh-token rotation + +Rotation replaces a valid refresh credential while preserving its association with the existing authentication context. + +```text +Refresh token + → Validate token, tenant, device and expected session (if supplied) + → Resolve user, session and chain + → Prepare replacement refresh/access tokens + → Atomically consume old refresh token and store replacement + → Return new credentials ``` -RefreshToken → Validate → Revoke → Issue New Tokens -``` -👉 This ensures forward security +The rotation service first rejects modes or configurations that do not permit refresh-token issuance (`PureOpaque` or `IssueRefresh = false`). It then validates the presented credential and its context. The replacement refresh token and access token are prepared before the existing token is consumed. Persistence uses the refresh-token store's execution boundary and `TryConsumeAsync`, so competing requests cannot both successfully consume the same old token. + +Rotation preserves the validated user, session and chain association; it is **not** a new authentication or a new session. Claims for the replacement access token are obtained through the configured claims provider. -
+### Reuse and concurrent requests -## ⚖️ Mode-Based Behavior -Refresh behavior is determined by the authentication mode: +A consumed refresh token cannot be used again to obtain another replacement. UltimateAuth distinguishes two situations: -| Mode | Behavior | -|-------------|-----------------------| -| PureOpaque | Session Touch | -| Hybrid | Rotation + Touch | -| SemiHybrid | Rotation | -| PureJwt | Rotation | +- **Likely concurrent request:** if a consumed token has a recorded replacement and the second request falls within `RefreshTokenConcurrentRequestWindow`, the second rotation is rejected without treating it as a confirmed replay. +- **Replay outside that window:** the rotation service revokes the associated refresh-token chain, or tokens associated with the session when a chain identifier is unavailable. -👉 UltimateAuth automatically selects the correct strategy. +This distinction reduces destructive responses to ordinary client-side concurrency while retaining a response to suspicious reuse. The concurrency window is **not** a grace period in which the old token may be exchanged again: the second request still fails. -
+The rotation implementation shown here revokes refresh-token state. Do not interpret that as an unconditional guarantee that every related server-side session is also revoked; session invalidation is a separate concern. -## 🔄 Step-by-Step Execution +## How each mode executes -### 1️⃣ Input Resolution -The system resolves: +### PureOpaque: session continuation -- SessionId (if present) -- RefreshToken (if present) -- Device context +`PureOpaque` has no refresh-token rotation step. The endpoint resolves the existing session from the authentication flow context, validates it and performs a conditional touch. It returns `Touched` when state was updated or `NoOp` when the valid session required no update. -### 2️⃣ Mode-Based Branching -The system determines the refresh strategy: +No access token or refresh token is issued. The existing session ID remains the credential representing the continued context. -- Session-based → Touch -- Token-based → Rotation -- Hybrid → Both +### Hybrid: session and token continuity -### 3️⃣ Session Validation -If session is involved: +`Hybrid` requires **both** a valid session ID and a refresh token. The flow: -- Session is validated -- Device binding is checked -- Expiration is evaluated +1. Validates the session and device context. +2. Performs the session touch operation according to policy. +3. Validates and rotates the refresh token, requiring it to match the expected session. +4. Returns the existing session ID together with replacement access and refresh tokens. -### 4️⃣ Token Validation (if applicable) -If refresh token is used: +The order is important: **session touch happens before token rotation** in the current implementation. These operations are not presented as one cross-store transaction. If rotation subsequently fails, the refresh response fails, although a preceding session touch may already have been persisted. This should not be described as an all-or-nothing operation. -- Token is validated -- Session and chain are verified -- Device consistency is checked +### SemiHybrid and PureJwt: implementation branches -### 5️⃣ Security Checks -The system enforces: +The current service includes branches for two additional modes: -- Token reuse detection -- Session validity -- Chain integrity +- `SemiHybrid` requires a session ID and refresh token, validates the session and rotates the token, but does not touch the session. +- `PureJwt` uses the refresh token for rotation without a session touch step in `RefreshFlowService`. -👉 If validation fails → reauthentication required +These describe the existing code paths, **not a promise of v0.1.0 production support**. Configuration validation and supported-mode documentation remain authoritative for which modes applications can enable. -### 6️⃣ Execution -Depending on the strategy: +## Request resolution and response delivery -#### Session Touch -- Updates `LastSeenAt` -- Applies sliding expiration -- No new tokens issued +The refresh endpoint uses the server's `AuthFlowContext`. It obtains the session ID from `flow.Session`, the device from `flow.Device`, and the refresh token through `RefreshTokenResolver`. -#### Token Rotation -- Revokes old refresh token -- Issues new access token -- Issues new refresh token +For refresh-token input, the resolver checks: -#### Hybrid Mode -- Validates session -- Rotates tokens -- Updates session activity +1. The explicit `X-Refresh-Token` header, if present. +2. The configured refresh cookie, when cookie delivery is active. +3. The configured refresh header, when header delivery is active. -### 7️⃣ Response Generation -The response may include: +The explicit header takes precedence. Header values may be supplied with a `Bearer ` prefix, which the resolver strips. -- SessionId -- Access token -- Refresh token +On success, the endpoint chooses a **primary credential** according to the refresh response policy, rather than automatically writing every credential returned by the flow. The refresh token is written separately when permitted by the response policy and when a replacement exists. Actual cookie/header placement is determined by the effective response configuration and client profile. -👉 Output depends on mode and client +The HTTP endpoint returns **`204 No Content`** on successful refresh and **`401 Unauthorized`** when continuation fails. Clients should obtain credentials from the configured response delivery mechanism rather than expecting a JSON token payload. When `Diagnostics.EnableRefreshDetails` is enabled, the endpoint also writes the refresh outcome through its diagnostic response writer. -
+> Response delivery customization is not fully independent in v0.1.0: mode templates and client-profile adaptation constrain the effective behavior. Do not assume every configured credential-delivery override is honored. -## 🔐 Security Model -The refresh flow includes strong protections: +## Outcomes and client expectations -### 🔁 Token Reuse Detection -If a refresh token is reused: +| Outcome | Meaning | Client interpretation | +| --- | --- | --- | +| `NoOp` | Existing session is valid; no touch was necessary | Continue normally | +| `Touched` | Existing session activity was updated | Continue normally | +| `Rotated` | Refresh token was consumed and replacement credentials issued | Replace stored credentials with the new values | +| `ReauthRequired` | The flow could not safely continue | Stop using the failed refresh credential and require authentication | -- Chain may be revoked -- Session may be revoked +`RefreshFlowResult` also recognizes `Success` as a successful outcome, although the mode-specific branches described above return `NoOp`, `Touched`, `Rotated` or `ReauthRequired`. -👉 This prevents replay attacks +Clients should serialize refresh attempts where practical. Parallel refresh requests using the same token do not produce multiple valid replacements, even when they fall within the concurrency window. -### 🔗 Session Binding -- Tokens are bound to session -- Session is bound to device +## Security boundaries -👉 Prevents token misuse across devices +Refresh continues existing trust only while the relevant state remains valid: -### 🧬 Chain Integrity -- Refresh operates within a chain -- Cross-device usage is rejected +- **Session and chain checks:** session-based paths validate active state, tenant, user, chain and session relationships. +- **Device context:** session and refresh-token validation receive the current device context; mismatches can prevent continuation. +- **Single-use refresh tokens:** successful rotation consumes the old credential and persists a replacement. +- **Replay response:** suspicious reuse can revoke refresh-token state associated with the chain or session. +- **Expiration:** idle, session, chain and refresh-token validity rules can end continuation. Refresh does not bypass those limits. +- **Issuance policy:** `IssueRefresh = false` prevents rotation before the old token is consumed. -
+These controls complement one another. A refresh token should be treated as a sensitive credential, not as a harmless request to generate another access token. -## 🧠 Mental Model -If you remember one thing: +## Summary -👉 Refresh = continuation -👉 Not new authentication +The useful distinction is not simply *stateful versus stateless*. It is **what must remain valid for authentication to continue**: -## 📌 Key Takeaways +- `PureOpaque` continues an existing server-side session, with conditional activity updates. +- `Hybrid` requires both session validity and successful refresh-token rotation. +- Other rotation branches exist, but are not part of the completed v0.1.0 supported-mode set. -- Refresh behavior depends on auth mode -- Stateful systems use session touch -- Stateless systems use token rotation -- Hybrid systems combine both -- Security is enforced at every step +Refresh does not authenticate a user again. It extends the usefulness of an already established authentication context only within the boundaries of the selected mode and configured security policies. -## ➡️ Next Step +## Next step -Continue to **Logout Flow** +Continue to [Logout Flow](./logout-flow). From b5044c8a3e30acb81a955349e15bbab4f71ded16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= <78308169+mckaragoz@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:43:21 +0300 Subject: [PATCH 20/20] Update v 0.1.0 With Docs Sample Page (#76) --- .../wwwroot/docs/auth-flows/refresh-flow.json | 90 +++++++------------ 1 file changed, 30 insertions(+), 60 deletions(-) diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/refresh-flow.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/refresh-flow.json index f0c9d34e..f7e92910 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/refresh-flow.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/auth-flows/refresh-flow.json @@ -1,106 +1,76 @@ { "Slug": "auth-flows/refresh-flow", "Title": "Refresh", - "Html": "\n\u003Cp\u003EThe refresh flow in UltimateAuth is not a single fixed operation.\u003C/p\u003E\n\u003Cp\u003E\uD83D\uDC49 It is a \u003Cstrong\u003Emode-dependent continuation strategy\u003C/strong\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-is-refresh\u0022\u003E\uD83E\uDDE0 What is Refresh?\u003C/h2\u003E\n\u003Cp\u003EIn traditional systems:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ERefresh = get a new access token\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EIn UltimateAuth:\u003C/p\u003E\n\u003Cp\u003E\uD83D\uDC49 Refresh continues an existing authentication state\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003ERefresh is not re-authentication\u003Cbr /\u003E\n\u2192 It is session continuation\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022two-refresh-strategies\u0022\u003E\uD83D\uDD00 Two Refresh Strategies\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports two fundamentally different refresh behaviors:\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-touch-stateful\u0022\u003E\uD83D\uDD39 Session Touch (Stateful)\u003C/h3\u003E\n\u003Cp\u003EUsed in \u003Cstrong\u003EPureOpaque mode\u003C/strong\u003E\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ENo tokens involved\u003C/li\u003E\n\u003Cli\u003ENo new session created\u003C/li\u003E\n\u003Cli\u003EExisting session is extended\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cpre\u003E\u003Ccode\u003ESession \u2192 Validate \u2192 Touch \u2192 Continue\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E\uD83D\uDC49 This updates activity without changing identity\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-rotation-stateless-hybrid\u0022\u003E\uD83D\uDD39 Token Rotation (Stateless / Hybrid)\u003C/h3\u003E\n\u003Cp\u003EUsed in:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cp\u003EHybrid\u003C/p\u003E\n\u003C/li\u003E\n\u003Cli\u003E\u003Cp\u003ESemiHybrid\u003C/p\u003E\n\u003C/li\u003E\n\u003Cli\u003E\u003Cp\u003EPureJwt\u003C/p\u003E\n\u003C/li\u003E\n\u003Cli\u003E\u003Cp\u003ERefresh token is validated\u003C/p\u003E\n\u003C/li\u003E\n\u003Cli\u003E\u003Cp\u003EOld token is revoked\u003C/p\u003E\n\u003C/li\u003E\n\u003Cli\u003E\u003Cp\u003ENew tokens are issued\u003C/p\u003E\n\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cpre\u003E\u003Ccode\u003ERefreshToken \u2192 Validate \u2192 Revoke \u2192 Issue New Tokens\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E\uD83D\uDC49 This ensures forward security\u003C/p\u003E\n\u003Cbr\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-based-behavior\u0022\u003E\u2696\uFE0F Mode-Based Behavior\u003C/h2\u003E\n\u003Cp\u003ERefresh behavior is determined by the authentication mode:\u003C/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPureOpaque\u003C/td\u003E\n\u003Ctd\u003ESession Touch\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHybrid\u003C/td\u003E\n\u003Ctd\u003ERotation \u002B Touch\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESemiHybrid\u003C/td\u003E\n\u003Ctd\u003ERotation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPureJwt\u003C/td\u003E\n\u003Ctd\u003ERotation\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\n\u003Cp\u003E\uD83D\uDC49 UltimateAuth automatically selects the correct strategy.\u003C/p\u003E\n\u003Cbr\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022step-by-step-execution\u0022\u003E\uD83D\uDD04 Step-by-Step Execution\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022input-resolution\u0022\u003E1\uFE0F\u20E3 Input Resolution\u003C/h3\u003E\n\u003Cp\u003EThe system resolves:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESessionId (if present)\u003C/li\u003E\n\u003Cli\u003ERefreshToken (if present)\u003C/li\u003E\n\u003Cli\u003EDevice context\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-based-branching\u0022\u003E2\uFE0F\u20E3 Mode-Based Branching\u003C/h3\u003E\n\u003Cp\u003EThe system determines the refresh strategy:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession-based \u2192 Touch\u003C/li\u003E\n\u003Cli\u003EToken-based \u2192 Rotation\u003C/li\u003E\n\u003Cli\u003EHybrid \u2192 Both\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-validation\u0022\u003E3\uFE0F\u20E3 Session Validation\u003C/h3\u003E\n\u003Cp\u003EIf session is involved:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESession is validated\u003C/li\u003E\n\u003Cli\u003EDevice binding is checked\u003C/li\u003E\n\u003Cli\u003EExpiration is evaluated\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-validation-if-applicable\u0022\u003E4\uFE0F\u20E3 Token Validation (if applicable)\u003C/h3\u003E\n\u003Cp\u003EIf refresh token is used:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EToken is validated\u003C/li\u003E\n\u003Cli\u003ESession and chain are verified\u003C/li\u003E\n\u003Cli\u003EDevice consistency is checked\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-checks\u0022\u003E5\uFE0F\u20E3 Security Checks\u003C/h3\u003E\n\u003Cp\u003EThe system enforces:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EToken reuse detection\u003C/li\u003E\n\u003Cli\u003ESession validity\u003C/li\u003E\n\u003Cli\u003EChain integrity\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 If validation fails \u2192 reauthentication required\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022execution\u0022\u003E6\uFE0F\u20E3 Execution\u003C/h3\u003E\n\u003Cp\u003EDepending on the strategy:\u003C/p\u003E\n\u003Ch4 id=\u0022session-touch\u0022\u003ESession Touch\u003C/h4\u003E\n\u003Cul\u003E\n\u003Cli\u003EUpdates \u003Ccode\u003ELastSeenAt\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EApplies sliding expiration\u003C/li\u003E\n\u003Cli\u003ENo new tokens issued\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch4 id=\u0022token-rotation\u0022\u003EToken Rotation\u003C/h4\u003E\n\u003Cul\u003E\n\u003Cli\u003ERevokes old refresh token\u003C/li\u003E\n\u003Cli\u003EIssues new access token\u003C/li\u003E\n\u003Cli\u003EIssues new refresh token\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch4 id=\u0022hybrid-mode\u0022\u003EHybrid Mode\u003C/h4\u003E\n\u003Cul\u003E\n\u003Cli\u003EValidates session\u003C/li\u003E\n\u003Cli\u003ERotates tokens\u003C/li\u003E\n\u003Cli\u003EUpdates session activity\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022response-generation\u0022\u003E7\uFE0F\u20E3 Response Generation\u003C/h3\u003E\n\u003Cp\u003EThe response may include:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ESessionId\u003C/li\u003E\n\u003Cli\u003EAccess token\u003C/li\u003E\n\u003Cli\u003ERefresh token\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 Output depends on mode and client\u003C/p\u003E\n\u003Cbr\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-model\u0022\u003E\uD83D\uDD10 Security Model\u003C/h2\u003E\n\u003Cp\u003EThe refresh flow includes strong protections:\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-reuse-detection\u0022\u003E\uD83D\uDD01 Token Reuse Detection\u003C/h3\u003E\n\u003Cp\u003EIf a refresh token is reused:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EChain may be revoked\u003C/li\u003E\n\u003Cli\u003ESession may be revoked\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 This prevents replay attacks\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-binding\u0022\u003E\uD83D\uDD17 Session Binding\u003C/h3\u003E\n\u003Cul\u003E\n\u003Cli\u003ETokens are bound to session\u003C/li\u003E\n\u003Cli\u003ESession is bound to device\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 Prevents token misuse across devices\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022chain-integrity\u0022\u003E\uD83E\uDDEC Chain Integrity\u003C/h3\u003E\n\u003Cul\u003E\n\u003Cli\u003ERefresh operates within a chain\u003C/li\u003E\n\u003Cli\u003ECross-device usage is rejected\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cbr\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022mental-model\u0022\u003E\uD83E\uDDE0 Mental Model\u003C/h2\u003E\n\u003Cp\u003EIf you remember one thing:\u003C/p\u003E\n\u003Cp\u003E\uD83D\uDC49 Refresh = continuation\u003Cbr /\u003E\n\uD83D\uDC49 Not new authentication\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022key-takeaways\u0022\u003E\uD83D\uDCCC Key Takeaways\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003ERefresh behavior depends on auth mode\u003C/li\u003E\n\u003Cli\u003EStateful systems use session touch\u003C/li\u003E\n\u003Cli\u003EStateless systems use token rotation\u003C/li\u003E\n\u003Cli\u003EHybrid systems combine both\u003C/li\u003E\n\u003Cli\u003ESecurity is enforced at every step\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003E\u27A1\uFE0F Next Step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Cstrong\u003ELogout Flow\u003C/strong\u003E\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003ERefresh is how an existing authentication context continues without asking the user to authenticate again. In UltimateAuth, it is not synonymous with issuing a new access token: the operation depends on the active authentication mode and on whether the context is represented by a server-side session, refresh credentials, or both.\u003C/p\u003E\n\u003Cp\u003EA successful refresh preserves the authenticated identity and its continuity. It does \u003Cstrong\u003Enot\u003C/strong\u003E prove the user\u0027s credentials again, create a new login, or necessarily extend every lifetime associated with the authentication.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022the-model-continuity-not-a-second-login\u0022\u003EThe model: continuity, not a second login\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth distinguishes three related operations:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EAuthentication\u003C/strong\u003E establishes a new authenticated context after the required proof is accepted.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ERefresh\u003C/strong\u003E attempts to continue an already established context, subject to its current validity and security policies.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EReauthentication\u003C/strong\u003E establishes trust again when continuation is unavailable or no longer permitted.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis distinction matters for both security and client behavior. A refresh request can fail because a session expired, a refresh credential is invalid or consumed, the device context does not match, or the underlying chain is no longer usable. The client should treat an unsuccessful refresh as a need to authenticate again, not as an invitation to retry the same credential indefinitely.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-dependent-strategies\u0022\u003EMode-dependent strategies\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth implements refresh through two building blocks: \u003Cstrong\u003Esession touch\u003C/strong\u003E and \u003Cstrong\u003Erefresh-token rotation\u003C/strong\u003E. A mode selects how they are combined.\u003C/p\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EAuthentication mode\u003C/th\u003E\n\u003Cth\u003ERequired refresh input\u003C/th\u003E\n\u003Cth\u003EStrategy\u003C/th\u003E\n\u003Cth\u003EResult\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession ID\u003C/td\u003E\n\u003Ctd\u003EValidate session; touch when policy requires\u003C/td\u003E\n\u003Ctd\u003EContinue the existing session; no new access or refresh token\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession ID \u003Cstrong\u003Eand\u003C/strong\u003E refresh token\u003C/td\u003E\n\u003Ctd\u003EValidate and conditionally touch session; rotate refresh token\u003C/td\u003E\n\u003Ctd\u003EContinue session and issue replacement tokens\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESemiHybrid\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession ID \u003Cstrong\u003Eand\u003C/strong\u003E refresh token\u003C/td\u003E\n\u003Ctd\u003EValidate session; rotate refresh token\u003C/td\u003E\n\u003Ctd\u003EIssue replacement tokens without a session touch\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh token\u003C/td\u003E\n\u003Ctd\u003EValidate and rotate refresh token\u003C/td\u003E\n\u003Ctd\u003EIssue replacement tokens; no session touch in the flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent support status:\u003C/strong\u003E \u003Ccode\u003EPureOpaque\u003C/code\u003E and \u003Ccode\u003EHybrid\u003C/code\u003E are the supported modes for the initial release. The \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E branches are present in the refresh implementation but should not be presented as completed, generally supported configurations in v0.1.0.\u003C/p\u003E\n\u003Cp\u003EThe mode is resolved from the effective authentication context on the server. Clients do not select a refresh algorithm independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-touch\u0022\u003ESession touch\u003C/h2\u003E\n\u003Cp\u003EA session touch is a controlled update to existing server-side authentication state. It does not issue a replacement credential or create a new session.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ESession ID\n \u2192 Validate session, chain and device context\n \u2192 Recheck active state and chain/session relationship\n \u2192 Touch if policy requires\n \u2192 Continue with the same session ID\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIn \u003Ccode\u003EPureOpaque\u003C/code\u003E, the refresh flow requires a session ID. The session validator checks the session against the current tenant, device context and session options. The touch service then reloads the session and chain from the store and checks that they are still active and consistent, including the chain\u0027s active session, tenant, user and chain identifiers.\u003C/p\u003E\n\u003Cp\u003ETouch behavior is governed by the effective session options:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E avoids writing activity state on every request. If the interval has not elapsed, the operation may succeed as \u003Cstrong\u003ENoOp\u003C/strong\u003E.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETouchMode\u003C/code\u003E is \u003Ccode\u003EIfNeeded\u003C/code\u003E by default; \u003Ccode\u003EForce\u003C/code\u003E requests a touch even when the normal interval has not elapsed.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ESlidingExpiration\u003C/code\u003E allows a touch to extend the session expiration using the configured \u003Ccode\u003ELifetime\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EAn absolute chain expiration remains an upper bound; sliding expiration cannot extend the session beyond it.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E participates in chain-state validation. An idle or otherwise inactive chain cannot be revived merely by touching it.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA successful \u003Cstrong\u003ENoOp\u003C/strong\u003E is not a failed refresh: the existing state was valid, but no persistence update was necessary. Likewise, a touch is not an unconditional promise to extend the session\u0027s expiry; that depends on policy and the absolute limit.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-token-rotation\u0022\u003ERefresh-token rotation\u003C/h2\u003E\n\u003Cp\u003ERotation replaces a valid refresh credential while preserving its association with the existing authentication context.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003ERefresh token\n \u2192 Validate token, tenant, device and expected session (if supplied)\n \u2192 Resolve user, session and chain\n \u2192 Prepare replacement refresh/access tokens\n \u2192 Atomically consume old refresh token and store replacement\n \u2192 Return new credentials\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EThe rotation service first rejects modes or configurations that do not permit refresh-token issuance (\u003Ccode\u003EPureOpaque\u003C/code\u003E or \u003Ccode\u003EIssueRefresh = false\u003C/code\u003E). It then validates the presented credential and its context. The replacement refresh token and access token are prepared before the existing token is consumed. Persistence uses the refresh-token store\u0027s execution boundary and \u003Ccode\u003ETryConsumeAsync\u003C/code\u003E, so competing requests cannot both successfully consume the same old token.\u003C/p\u003E\n\u003Cp\u003ERotation preserves the validated user, session and chain association; it is \u003Cstrong\u003Enot\u003C/strong\u003E a new authentication or a new session. Claims for the replacement access token are obtained through the configured claims provider.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022reuse-and-concurrent-requests\u0022\u003EReuse and concurrent requests\u003C/h3\u003E\n\u003Cp\u003EA consumed refresh token cannot be used again to obtain another replacement. UltimateAuth distinguishes two situations:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ELikely concurrent request:\u003C/strong\u003E if a consumed token has a recorded replacement and the second request falls within \u003Ccode\u003ERefreshTokenConcurrentRequestWindow\u003C/code\u003E, the second rotation is rejected without treating it as a confirmed replay.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EReplay outside that window:\u003C/strong\u003E the rotation service revokes the associated refresh-token chain, or tokens associated with the session when a chain identifier is unavailable.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis distinction reduces destructive responses to ordinary client-side concurrency while retaining a response to suspicious reuse. The concurrency window is \u003Cstrong\u003Enot\u003C/strong\u003E a grace period in which the old token may be exchanged again: the second request still fails.\u003C/p\u003E\n\u003Cp\u003EThe rotation implementation shown here revokes refresh-token state. Do not interpret that as an unconditional guarantee that every related server-side session is also revoked; session invalidation is a separate concern.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-each-mode-executes\u0022\u003EHow each mode executes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pureopaque-session-continuation\u0022\u003EPureOpaque: session continuation\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E has no refresh-token rotation step. The endpoint resolves the existing session from the authentication flow context, validates it and performs a conditional touch. It returns \u003Ccode\u003ETouched\u003C/code\u003E when state was updated or \u003Ccode\u003ENoOp\u003C/code\u003E when the valid session required no update.\u003C/p\u003E\n\u003Cp\u003ENo access token or refresh token is issued. The existing session ID remains the credential representing the continued context.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hybrid-session-and-token-continuity\u0022\u003EHybrid: session and token continuity\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EHybrid\u003C/code\u003E requires \u003Cstrong\u003Eboth\u003C/strong\u003E a valid session ID and a refresh token. The flow:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EValidates the session and device context.\u003C/li\u003E\n\u003Cli\u003EPerforms the session touch operation according to policy.\u003C/li\u003E\n\u003Cli\u003EValidates and rotates the refresh token, requiring it to match the expected session.\u003C/li\u003E\n\u003Cli\u003EReturns the existing session ID together with replacement access and refresh tokens.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe order is important: \u003Cstrong\u003Esession touch happens before token rotation\u003C/strong\u003E in the current implementation. These operations are not presented as one cross-store transaction. If rotation subsequently fails, the refresh response fails, although a preceding session touch may already have been persisted. This should not be described as an all-or-nothing operation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022semihybrid-and-purejwt-implementation-branches\u0022\u003ESemiHybrid and PureJwt: implementation branches\u003C/h3\u003E\n\u003Cp\u003EThe current service includes branches for two additional modes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ESemiHybrid\u003C/code\u003E requires a session ID and refresh token, validates the session and rotates the token, but does not touch the session.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E uses the refresh token for rotation without a session touch step in \u003Ccode\u003ERefreshFlowService\u003C/code\u003E.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese describe the existing code paths, \u003Cstrong\u003Enot a promise of v0.1.0 production support\u003C/strong\u003E. Configuration validation and supported-mode documentation remain authoritative for which modes applications can enable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022request-resolution-and-response-delivery\u0022\u003ERequest resolution and response delivery\u003C/h2\u003E\n\u003Cp\u003EThe refresh endpoint uses the server\u0027s \u003Ccode\u003EAuthFlowContext\u003C/code\u003E. It obtains the session ID from \u003Ccode\u003Eflow.Session\u003C/code\u003E, the device from \u003Ccode\u003Eflow.Device\u003C/code\u003E, and the refresh token through \u003Ccode\u003ERefreshTokenResolver\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor refresh-token input, the resolver checks:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003EThe explicit \u003Ccode\u003EX-Refresh-Token\u003C/code\u003E header, if present.\u003C/li\u003E\n\u003Cli\u003EThe configured refresh cookie, when cookie delivery is active.\u003C/li\u003E\n\u003Cli\u003EThe configured refresh header, when header delivery is active.\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThe explicit header takes precedence. Header values may be supplied with a \u003Ccode\u003EBearer \u003C/code\u003E prefix, which the resolver strips.\u003C/p\u003E\n\u003Cp\u003EOn success, the endpoint chooses a \u003Cstrong\u003Eprimary credential\u003C/strong\u003E according to the refresh response policy, rather than automatically writing every credential returned by the flow. The refresh token is written separately when permitted by the response policy and when a replacement exists. Actual cookie/header placement is determined by the effective response configuration and client profile.\u003C/p\u003E\n\u003Cp\u003EThe HTTP endpoint returns \u003Cstrong\u003E\u003Ccode\u003E204 No Content\u003C/code\u003E\u003C/strong\u003E on successful refresh and \u003Cstrong\u003E\u003Ccode\u003E401 Unauthorized\u003C/code\u003E\u003C/strong\u003E when continuation fails. Clients should obtain credentials from the configured response delivery mechanism rather than expecting a JSON token payload. When \u003Ccode\u003EDiagnostics.EnableRefreshDetails\u003C/code\u003E is enabled, the endpoint also writes the refresh outcome through its diagnostic response writer.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EResponse delivery customization is not fully independent in v0.1.0: mode templates and client-profile adaptation constrain the effective behavior. Do not assume every configured credential-delivery override is honored.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022outcomes-and-client-expectations\u0022\u003EOutcomes and client expectations\u003C/h2\u003E\n\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOutcome\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003Cth\u003EClient interpretation\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENoOp\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting session is valid; no touch was necessary\u003C/td\u003E\n\u003Ctd\u003EContinue normally\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouched\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExisting session activity was updated\u003C/td\u003E\n\u003Ctd\u003EContinue normally\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERotated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh token was consumed and replacement credentials issued\u003C/td\u003E\n\u003Ctd\u003EReplace stored credentials with the new values\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EReauthRequired\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe flow could not safely continue\u003C/td\u003E\n\u003Ctd\u003EStop using the failed refresh credential and require authentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\n\u003Cp\u003E\u003Ccode\u003ERefreshFlowResult\u003C/code\u003E also recognizes \u003Ccode\u003ESuccess\u003C/code\u003E as a successful outcome, although the mode-specific branches described above return \u003Ccode\u003ENoOp\u003C/code\u003E, \u003Ccode\u003ETouched\u003C/code\u003E, \u003Ccode\u003ERotated\u003C/code\u003E or \u003Ccode\u003EReauthRequired\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EClients should serialize refresh attempts where practical. Parallel refresh requests using the same token do not produce multiple valid replacements, even when they fall within the concurrency window.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-boundaries\u0022\u003ESecurity boundaries\u003C/h2\u003E\n\u003Cp\u003ERefresh continues existing trust only while the relevant state remains valid:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003ESession and chain checks:\u003C/strong\u003E session-based paths validate active state, tenant, user, chain and session relationships.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EDevice context:\u003C/strong\u003E session and refresh-token validation receive the current device context; mismatches can prevent continuation.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003ESingle-use refresh tokens:\u003C/strong\u003E successful rotation consumes the old credential and persists a replacement.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EReplay response:\u003C/strong\u003E suspicious reuse can revoke refresh-token state associated with the chain or session.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EExpiration:\u003C/strong\u003E idle, session, chain and refresh-token validity rules can end continuation. Refresh does not bypass those limits.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EIssuance policy:\u003C/strong\u003E \u003Ccode\u003EIssueRefresh = false\u003C/code\u003E prevents rotation before the old token is consumed.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese controls complement one another. A refresh token should be treated as a sensitive credential, not as a harmless request to generate another access token.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022summary\u0022\u003ESummary\u003C/h2\u003E\n\u003Cp\u003EThe useful distinction is not simply \u003Cem\u003Estateful versus stateless\u003C/em\u003E. It is \u003Cstrong\u003Ewhat must remain valid for authentication to continue\u003C/strong\u003E:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E continues an existing server-side session, with conditional activity updates.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EHybrid\u003C/code\u003E requires both session validity and successful refresh-token rotation.\u003C/li\u003E\n\u003Cli\u003EOther rotation branches exist, but are not part of the completed v0.1.0 supported-mode set.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERefresh does not authenticate a user again. It extends the usefulness of an already established authentication context only within the boundaries of the selected mode and configured security policies.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-step\u0022\u003ENext step\u003C/h2\u003E\n\u003Cp\u003EContinue to \u003Ca href=\u0022./logout-flow\u0022\u003ELogout Flow\u003C/a\u003E.\u003C/p\u003E\n", "Headings": [ { - "Id": "what-is-refresh", - "Text": "\uD83E\uDDE0 What is Refresh?", + "Id": "the-model-continuity-not-a-second-login", + "Text": "The model: continuity, not a second login", "Level": 0 }, { - "Id": "two-refresh-strategies", - "Text": "\uD83D\uDD00 Two Refresh Strategies", + "Id": "mode-dependent-strategies", + "Text": "Mode-dependent strategies", "Level": 0 }, { - "Id": "session-touch-stateful", - "Text": "\uD83D\uDD39 Session Touch (Stateful)", - "Level": 1 - }, - { - "Id": "token-rotation-stateless-hybrid", - "Text": "\uD83D\uDD39 Token Rotation (Stateless / Hybrid)", - "Level": 1 - }, - { - "Id": "mode-based-behavior", - "Text": "\u2696\uFE0F Mode-Based Behavior", + "Id": "session-touch", + "Text": "Session touch", "Level": 0 }, { - "Id": "step-by-step-execution", - "Text": "\uD83D\uDD04 Step-by-Step Execution", + "Id": "refresh-token-rotation", + "Text": "Refresh-token rotation", "Level": 0 }, { - "Id": "input-resolution", - "Text": "1\uFE0F\u20E3 Input Resolution", - "Level": 1 - }, - { - "Id": "mode-based-branching", - "Text": "2\uFE0F\u20E3 Mode-Based Branching", - "Level": 1 - }, - { - "Id": "session-validation", - "Text": "3\uFE0F\u20E3 Session Validation", + "Id": "reuse-and-concurrent-requests", + "Text": "Reuse and concurrent requests", "Level": 1 }, { - "Id": "token-validation-if-applicable", - "Text": "4\uFE0F\u20E3 Token Validation (if applicable)", - "Level": 1 + "Id": "how-each-mode-executes", + "Text": "How each mode executes", + "Level": 0 }, { - "Id": "security-checks", - "Text": "5\uFE0F\u20E3 Security Checks", + "Id": "pureopaque-session-continuation", + "Text": "PureOpaque: session continuation", "Level": 1 }, { - "Id": "execution", - "Text": "6\uFE0F\u20E3 Execution", + "Id": "hybrid-session-and-token-continuity", + "Text": "Hybrid: session and token continuity", "Level": 1 }, { - "Id": "response-generation", - "Text": "7\uFE0F\u20E3 Response Generation", + "Id": "semihybrid-and-purejwt-implementation-branches", + "Text": "SemiHybrid and PureJwt: implementation branches", "Level": 1 }, { - "Id": "security-model", - "Text": "\uD83D\uDD10 Security Model", + "Id": "request-resolution-and-response-delivery", + "Text": "Request resolution and response delivery", "Level": 0 }, { - "Id": "token-reuse-detection", - "Text": "\uD83D\uDD01 Token Reuse Detection", - "Level": 1 - }, - { - "Id": "session-binding", - "Text": "\uD83D\uDD17 Session Binding", - "Level": 1 - }, - { - "Id": "chain-integrity", - "Text": "\uD83E\uDDEC Chain Integrity", - "Level": 1 + "Id": "outcomes-and-client-expectations", + "Text": "Outcomes and client expectations", + "Level": 0 }, { - "Id": "mental-model", - "Text": "\uD83E\uDDE0 Mental Model", + "Id": "security-boundaries", + "Text": "Security boundaries", "Level": 0 }, { - "Id": "key-takeaways", - "Text": "\uD83D\uDCCC Key Takeaways", + "Id": "summary", + "Text": "Summary", "Level": 0 }, { "Id": "next-step", - "Text": "\u27A1\uFE0F Next Step", + "Text": "Next step", "Level": 0 } ]