From d90b408f1bfb6f312c85b544227f1d462ca8723c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 19 Oct 2026 22:27:51 +0300 Subject: [PATCH 1/4] fix(blazor): enforce UltimateAuth authorization at route level --- .../Components/UAuthApp.razor | 33 +- .../Components/UAuthApp.razor.cs | 3 + .../Components/UAuthAuthorizeRouteView.razor | 49 ++ .../UAuthAuthorizeRouteView.razor.cs | 174 +++++ .../Blazor/UAuthAuthorizeRouteViewTests.cs | 592 ++++++++++++++++++ 5 files changed, 835 insertions(+), 16 deletions(-) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor index d0b1a1ac..3f554225 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor @@ -1,6 +1,5 @@ @namespace CodeBeam.UltimateAuth.Client.Blazor -@using CodeBeam.UltimateAuth.Client.Contracts @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Routing @inject IUAuthStateManager StateManager @@ -9,32 +8,34 @@ - @if (RenderMode == UAuthRenderMode.Reactive) - { - + + @if (RenderMode == UAuthRenderMode.Reactive) + { + + @ChildContent + @if (UseBuiltInRouter) + { + @RenderRouter() + } + + } + else + { @ChildContent @if (UseBuiltInRouter) { @RenderRouter() } - - } - else - { - @ChildContent - @if (UseBuiltInRouter) - { - @RenderRouter() } - } + @code { private RenderFragment RenderRouter() => @ - - + + @if (NotAuthorized is not null) { @NotAuthorized @@ -44,7 +45,7 @@

Not authorized

}
-
+
; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs index 46ec4b79..ad9dfe28 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs @@ -27,6 +27,7 @@ public partial class UAuthApp { private bool _initialized; private bool _coordinatorStarted; + private bool _runtimeReady; /// /// Gets or sets the application content rendered within the UltimateAuth context. @@ -132,6 +133,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await Bootstrapper.EnsureStartedAsync(); await StateManager.EnsureAsync(); + _runtimeReady = true; + if (StateManager.State.IsAuthenticated) { await Coordinator.StartAsync(); diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor new file mode 100644 index 00000000..64fd6f69 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor @@ -0,0 +1,49 @@ +@namespace CodeBeam.UltimateAuth.Client.Blazor + +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Routing + +@implements IDisposable + +@if (!_hasUAuthRequirement || (_runtimeReady && _uauthAuthorized)) +{ + + + + @if (NotAuthorized is not null) + { + @NotAuthorized(authenticationState) + } + else + { +

Not authorized.

+ } +
+ + + @if (Authorizing is not null) + { + @Authorizing + } + + +
+} +else if (!_runtimeReady) +{ + @if (Authorizing is not null) + { + @Authorizing + } +} +else +{ + @if (NotAuthorized is not null) + { + @NotAuthorized(new AuthenticationState(AuthState.ToClaimsPrincipal())) + } + else + { +

Not authorized.

+ } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs new file mode 100644 index 00000000..bd9eef88 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs @@ -0,0 +1,174 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +/// +/// Renders a routed Blazor page using both UltimateAuth and ASP.NET Core authorization requirements. +/// +public partial class UAuthAuthorizeRouteView : ComponentBase, IDisposable +{ + private UAuthState? _subscribedState; + private UAuthAuthorizeAttribute? _requirement; + + private bool _hasUAuthRequirement; + private bool _uauthAuthorized; + private bool _runtimeReady; + private bool _disposed; + + /// + /// The route to render. + /// + [Parameter, EditorRequired] + public RouteData RouteData { get; set; } = default!; + + /// + /// The default layout for the routed page. + /// + [Parameter] + public Type? DefaultLayout { get; set; } + + /// + /// Optional resource passed to ASP.NET Core authorization. + /// + [Parameter] + public object? Resource { get; set; } + + /// + /// Content displayed when authorization fails. + /// + [Parameter] + public RenderFragment? NotAuthorized { get; set; } + + /// + /// Content displayed while authorization is pending. + /// + [Parameter] + public RenderFragment? Authorizing { get; set; } + + /// + /// The current UltimateAuth state. + /// + [CascadingParameter] + private UAuthState? CurrentAuthState { get; set; } + + /// + /// Indicates whether UltimateAuth runtime initialization + /// has completed. + /// + [CascadingParameter(Name = "UAuthRuntimeReady")] + private bool RuntimeReady { get; set; } + + private UAuthState AuthState => CurrentAuthState + ?? throw new InvalidOperationException($"{nameof(UAuthAuthorizeRouteView)} requires " + $"a cascading {nameof(UAuthState)}."); + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + ArgumentNullException.ThrowIfNull(RouteData); + + _requirement = RouteData.PageType.GetCustomAttribute(inherit: true); + + _hasUAuthRequirement = _requirement is not null; + + // Public routes remain under the standard + // ASP.NET Core AuthorizeRouteView. + if (!_hasUAuthRequirement) + { + _uauthAuthorized = true; + Unsubscribe(); + return; + } + + Subscribe(AuthState); + + _runtimeReady = RuntimeReady; + + EvaluateAuthorization(); + } + + private void EvaluateAuthorization() + { + // Fail closed until UltimateAuth initialization + // has completed. + _uauthAuthorized = false; + + if (!_runtimeReady || _requirement is null) + return; + + if (!AuthState.IsAuthenticated) + return; + + var roles = ParseValues(_requirement.Roles); + var permissions = ParseValues(_requirement.Permissions); + + // Category semantics: + // At least one role AND at least one permission, + // when both categories are configured. + if (roles.Length > 0 && !roles.Any(AuthState.IsInRole)) + { + return; + } + + if (permissions.Length > 0 && !permissions.Any(AuthState.HasPermission)) + { + return; + } + + _uauthAuthorized = true; + } + + private static string[] ParseValues(string? values) + { + if (string.IsNullOrWhiteSpace(values)) + return []; + + return values + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToArray(); + } + + private void Subscribe(UAuthState state) + { + if (ReferenceEquals(_subscribedState, state)) + return; + + Unsubscribe(); + + _subscribedState = state; + _subscribedState.Changed += OnAuthStateChanged; + } + + private void Unsubscribe() + { + if (_subscribedState is null) + return; + + _subscribedState.Changed -= OnAuthStateChanged; + _subscribedState = null; + } + + private void OnAuthStateChanged(UAuthStateChangeReason reason) + { + if (_disposed) + return; + + _ = InvokeAsync(() => + { + if (_disposed) + return; + + EvaluateAuthorization(); + StateHasChanged(); + }); + } + + public void Dispose() + { + _disposed = true; + Unsubscribe(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs new file mode 100644 index 00000000..66c393d3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs @@ -0,0 +1,592 @@ +using Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Rendering; +using Bunit.TestDoubles; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +/// +/// Contract tests for UltimateAuth route authorization. These exercise the +/// actual route view, rather than duplicating its authorization algorithm. +/// +public sealed class UAuthAuthorizeRouteViewTests : BunitContext +{ + private const string Denied = "ROUTE-DENIED"; + private const string Loading = "ROUTE-LOADING"; + + private readonly BunitAuthorizationContext _authorization; + + public UAuthAuthorizeRouteViewTests() + { + _authorization = AddAuthorization(); + _authorization.SetNotAuthorized(); + } + + [Fact] + public void Anonymous_ProtectedRoute_DoesNotInstantiatePage() + { + var cut = RenderRoute(TestAuthState.Anonymous()); + AssertDenied(cut); + } + + [Fact] + public void Authenticated_ProtectedRoute_RendersPage_WithoutBaseClass() + { + var cut = RenderRoute(TestAuthState.Authenticated()); + AssertVisible(cut); + } + + [Fact] + public void ProtectedRoute_BeforeRuntimeReady_DoesNotInstantiatePage() + { + var cut = RenderRoute(TestAuthState.Authenticated(), ready: false); + + cut.Markup.Should().Contain(Loading); + cut.FindComponents().Should().BeEmpty(); + } + + [Fact] + public void Anonymous_ProtectedRoute_BeforeRuntimeReady_ShowsLoadingNotDenied() + { + var cut = RenderRoute(TestAuthState.Anonymous(), ready: false); + + cut.Markup.Should().Contain(Loading); + cut.Markup.Should().NotContain(Denied); + cut.FindComponents().Should().BeEmpty(); + } + + [Fact] + public void RuntimeBecomesReady_ReevaluatesProtectedRoute() + { + var state = TestAuthState.Authenticated(); + var cut = RenderHost(typeof(ProtectedPage), state, ready: false); + + cut.FindComponents().Should().BeEmpty(); + cut.Render(p => p + .Add(x => x.PageType, typeof(ProtectedPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, true)); + + AssertVisible(cut); + } + + [Fact] + public void MissingRole_DeniesProtectedRoute() + { + var cut = RenderRoute(TestAuthState.WithRoles("User")); + AssertDenied(cut); + } + + [Fact] + public void MatchingRole_AllowsProtectedRoute() + { + var cut = RenderRoute(TestAuthState.WithRoles("Admin")); + AssertVisible(cut); + } + + [Fact] + public void RoleCsv_AllowsAnyMatchingRole_AndTrimsWhitespace() + { + var cut = RenderRoute(TestAuthState.WithRoles("Manager")); + AssertVisible(cut); + } + + [Fact] + public void MissingPermission_DeniesProtectedRoute() + { + var cut = RenderRoute(TestAuthState.WithPermissions("users.list")); + AssertDenied(cut); + } + + [Fact] + public void MatchingPermission_AllowsProtectedRoute() + { + var cut = RenderRoute(TestAuthState.WithPermissions("users.read")); + AssertVisible(cut); + } + + [Fact] + public void RoleAndPermission_RequireMatchInBothCategories() + { + var onlyRole = RenderRoute(TestAuthState.Create(roles: ["Admin"], permissions: ["users.list"])); + + AssertDenied(onlyRole); + + var both = RenderRoute(TestAuthState.Create(roles: ["Admin"], permissions: ["users.read"])); + + AssertVisible(both); + } + + [Fact] + public void PublicRoute_DoesNotRequireUltimateAuthAuthenticationOrReadiness() + { + var cut = RenderRoute(TestAuthState.Anonymous(), ready: false); + AssertVisible(cut); + } + + [Fact] + public void StandardAuthorize_AnonymousUser_DeniesRoute() + { + var cut = RenderRoute(TestAuthState.Authenticated()); + AssertDenied(cut); + } + + [Fact] + public void StandardAuthorize_AuthorizedUser_AllowsRoute() + { + _authorization.SetAuthorized("aspnet-user"); + var cut = RenderRoute(TestAuthState.Anonymous()); + AssertVisible(cut); + } + + [Fact] + public void CombinedAttributes_RequireBothUltimateAuthAndAspNetAuthorization() + { + // ASP.NET authorized, UltimateAuth anonymous. + _authorization.SetAuthorized("aspnet-user"); + + var cut = RenderRoute( + TestAuthState.Anonymous()); + + AssertDenied(cut); + + // UltimateAuth authorized, ASP.NET unauthorized. + _authorization.SetNotAuthorized(); + + cut.Render(p => p + .Add(x => x.PageType, typeof(CombinedPage)) + .Add(x => x.State, TestAuthState.WithRoles("Admin")) + .Add(x => x.Ready, true)); + + AssertDenied(cut); + } + + [Fact] + public void RouteChange_ReevaluatesAttributeAndPreventsOldPageRendering() + { + var state = TestAuthState.WithRoles("User"); + var cut = RenderHost(typeof(PublicPage), state); + AssertVisible(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(AdminPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, true)); + + AssertDenied(cut); + cut.FindComponents().Should().BeEmpty(); + } + + [Fact] + public void RouteChange_FromDeniedToPublic_RendersPublicPage() + { + var state = TestAuthState.Anonymous(); + var cut = RenderHost(typeof(ProtectedPage), state); + AssertDenied(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(PublicPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, true)); + + AssertVisible(cut); + } + + [Fact] + public void Logout_RemovesPreviouslyAuthorizedPage() + { + var state = TestAuthState.Authenticated(); + var cut = RenderRoute(state); + AssertVisible(cut); + + cut.InvokeAsync(state.Clear); + cut.WaitForAssertion(() => AssertDenied(cut)); + } + + [Fact] + public void Login_ShowsPreviouslyDeniedPage() + { + var state = TestAuthState.Anonymous(); + var cut = RenderRoute(state); + AssertDenied(cut); + + // TestAuthState.Authenticated creates a snapshot; applying it to the + // SAME state instance tests the Changed event subscription. + var authenticated = TestAuthState.Authenticated(); + cut.InvokeAsync(() => state.ApplySnapshot( + new AuthStateSnapshot + { + Identity = authenticated.Identity ?? throw new InvalidOperationException("Authenticated test state must contain an identity."), + Claims = authenticated.Claims + }, DateTimeOffset.UtcNow)); + + cut.WaitForAssertion(() => AssertVisible(cut)); + } + + [Fact] + public void CombinedAttributes_WhenBothAuthorized_RendersPage() + { + _authorization.SetAuthorized("aspnet-user"); + + var cut = RenderRoute(TestAuthState.WithRoles("Admin")); + + AssertVisible(cut); + } + + [Fact] + public void ReadinessDrops_AuthorizedRouteIsImmediatelyRemoved() + { + var state = TestAuthState.Authenticated(); + var cut = RenderRoute(state); + AssertVisible(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(ProtectedPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, false)); + + cut.FindComponents().Should().BeEmpty(); + cut.Markup.Should().Contain(Loading); + } + + [Fact] + public void ProtectedToProtected_DoesNotKeepPreviouslyAuthorizedPage() + { + var state = TestAuthState.WithRoles("User"); + var cut = RenderRoute(state); + AssertVisible(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(AdminPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, true)); + + AssertDenied(cut); + cut.FindComponents().Should().BeEmpty(); + } + + [Fact] + public void ProtectedToProtected_ReevaluatesDifferentRequirements() + { + var state = TestAuthState.WithRoles("Admin"); + var cut = RenderRoute(state); + AssertDenied(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(AdminPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, true)); + + AssertVisible(cut); + } + + [Fact] + public void StateInstanceReplacement_ReevaluatesAuthorization() + { + var cut = RenderRoute(TestAuthState.WithRoles("Admin")); + AssertVisible(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(AdminPage)) + .Add(x => x.State, TestAuthState.WithRoles("User")) + .Add(x => x.Ready, true)); + + AssertDenied(cut); + } + + [Fact] + public void PublicRoute_RemainsVisibleWhenReadinessChanges() + { + var state = TestAuthState.Anonymous(); + var cut = RenderRoute(state, ready: true); + AssertVisible(cut); + + cut.Render(p => p + .Add(x => x.PageType, typeof(PublicPage)) + .Add(x => x.State, state) + .Add(x => x.Ready, false)); + + AssertVisible(cut); + } + + [Fact] + public void RoleRevocation_OnSameStateInstance_RemovesPage() + { + var state = TestAuthState.WithRoles("Admin"); + var cut = RenderRoute(state); + AssertVisible(cut); + + var replacement = TestAuthState.WithRoles("User"); + + cut.InvokeAsync(() => state.ApplySnapshot(new AuthStateSnapshot + { + Identity = replacement.Identity + ?? throw new InvalidOperationException("Expected identity."), + Claims = replacement.Claims + }, DateTimeOffset.UtcNow)); + + cut.WaitForAssertion(() => AssertDenied(cut)); + } + + [Fact] + public void PermissionRevocation_OnSameStateInstance_RemovesPage() + { + var state = TestAuthState.WithPermissions("users.read"); + var cut = RenderRoute(state); + AssertVisible(cut); + + var replacement = TestAuthState.WithPermissions("users.list"); + + cut.InvokeAsync(() => state.ApplySnapshot(new AuthStateSnapshot + { + Identity = replacement.Identity + ?? throw new InvalidOperationException("Expected identity."), + Claims = replacement.Claims + }, DateTimeOffset.UtcNow)); + + cut.WaitForAssertion(() => AssertDenied(cut)); + } + + [Fact] + public void MultipleLoginLogoutTransitions_KeepRouteSynchronized() + { + var state = TestAuthState.Anonymous(); + var cut = RenderRoute(state); + AssertDenied(cut); + + for (var i = 0; i < 3; i++) + { + var authenticated = TestAuthState.Authenticated(); + + cut.InvokeAsync(() => state.ApplySnapshot(new AuthStateSnapshot + { + Identity = authenticated.Identity + ?? throw new InvalidOperationException("Expected identity."), + Claims = authenticated.Claims + }, DateTimeOffset.UtcNow)); + + cut.WaitForAssertion(() => AssertVisible(cut)); + + cut.InvokeAsync(state.Clear); + + cut.WaitForAssertion(() => AssertDenied(cut)); + } + } + + [Fact] + public void StandardRoleRequirement_WithoutMatchingRole_DeniesRoute() + { + _authorization.SetAuthorized("aspnet-user"); + + var cut = RenderRoute( + TestAuthState.Authenticated()); + + AssertDenied(cut); + } + + [Fact] + public void StandardRoleRequirement_WithMatchingRole_AllowsRoute() + { + _authorization.SetAuthorized("aspnet-user"); + _authorization.SetRoles("Admin"); + + var cut = RenderRoute( + TestAuthState.Anonymous()); + + AssertVisible(cut); + } + + [Fact] + public void StandardPolicyRequirement_WithoutPolicy_DeniesRoute() + { + _authorization.SetAuthorized("aspnet-user"); + + var cut = RenderRoute( + TestAuthState.Anonymous()); + + AssertDenied(cut); + } + + [Fact] + public void StandardPolicyRequirement_WithPolicy_AllowsRoute() + { + _authorization.SetAuthorized("aspnet-user"); + _authorization.SetPolicies("CanManageUsers"); + + var cut = RenderRoute( + TestAuthState.Anonymous()); + + AssertVisible(cut); + } + + [Fact] + public void CombinedAttributes_StandardRoleStillRequiredWhenUltimateAuthSucceeds() + { + _authorization.SetAuthorized("aspnet-user"); + + var cut = RenderRoute( + TestAuthState.WithRoles("Admin")); + + AssertDenied(cut); + } + + [Fact] + public void CombinedAttributes_BothIndependentRoleRequirementsSatisfied_AllowsRoute() + { + _authorization.SetAuthorized("aspnet-user"); + _authorization.SetRoles("Manager"); + + var cut = RenderRoute( + TestAuthState.WithRoles("Admin")); + + AssertVisible(cut); + } + + [Fact] + public void UnauthorizedPage_OnInitializedNeverRuns() + { + InitializationProbePage.Initializations = 0; + + var cut = RenderRoute( + TestAuthState.Anonymous()); + + AssertDenied(cut); + + InitializationProbePage.Initializations.Should().Be(0); + } + + [Fact] + public void AuthorizedPage_OnInitializedRunsExactlyOnce() + { + InitializationProbePage.Initializations = 0; + + var cut = RenderRoute( + TestAuthState.Authenticated()); + + AssertVisible(cut); + + InitializationProbePage.Initializations.Should().Be(1); + } + + + private IRenderedComponent RenderRoute(UAuthState state, bool ready = true) + where TPage : IComponent + => RenderHost(typeof(TPage), state, ready); + + private IRenderedComponent RenderHost(Type pageType, UAuthState state, bool ready = true) + => Render(p => p + .Add(x => x.PageType, pageType) + .Add(x => x.State, state) + .Add(x => x.Ready, ready)); + + private static void AssertDenied(IRenderedComponent cut) + where TPage : IComponent + { + cut.Markup.Should().Contain(Denied); + cut.FindComponents().Should().BeEmpty(); + } + + private static void AssertVisible(IRenderedComponent cut) + where TPage : IComponent + { + cut.FindComponents().Should().ContainSingle(); + cut.Markup.Should().NotContain(Denied); + cut.Markup.Should().NotContain(Loading); + } + + /// + /// A minimal equivalent of UAuthApp's cascading state and readiness. + /// Uses the REAL UAuthAuthorizeRouteView and nested AuthorizeRouteView. + /// + public sealed class RouteHost : ComponentBase + { + [Parameter] public Type PageType { get; set; } = default!; + [Parameter] public UAuthState State { get; set; } = default!; + [Parameter] public bool Ready { get; set; } = true; + + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenComponent>(0); + b.AddAttribute(1, "Value", State); + b.AddAttribute(2, "ChildContent", (RenderFragment)(outer => + { + outer.OpenComponent>(0); + outer.AddAttribute(1, "Name", "UAuthRuntimeReady"); + outer.AddAttribute(2, "Value", Ready); + outer.AddAttribute(3, "ChildContent", (RenderFragment)(inner => + { + inner.OpenComponent(0); + inner.AddAttribute(1, "RouteData", new RouteData( + PageType, new Dictionary())); + inner.AddAttribute(2, "NotAuthorized", + (RenderFragment)(_ => content => + content.AddContent(0, Denied))); + inner.AddAttribute(3, "Authorizing", + (RenderFragment)(content => content.AddContent(0, Loading))); + inner.CloseComponent(); + })); + outer.CloseComponent(); + })); + b.CloseComponent(); + } + } + + public abstract class TestPage : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder b) + => b.AddContent(0, GetType().Name); + } + + public sealed class PublicPage : TestPage { } + + [UAuthAuthorize] + public sealed class ProtectedPage : TestPage { } + + [UAuthAuthorize(Roles = "Admin")] + public sealed class AdminPage : TestPage { } + + [UAuthAuthorize(Roles = " User, , Manager, ")] + public sealed class MultiRolePage : TestPage { } + + [UAuthAuthorize(Permissions = "users.read")] + public sealed class PermissionPage : TestPage { } + + [UAuthAuthorize(Roles = "Admin", Permissions = "users.read")] + public sealed class RoleAndPermissionPage : TestPage { } + + [Authorize] + public sealed class StandardAuthorizePage : TestPage { } + + [Authorize] + [UAuthAuthorize(Roles = "Admin")] + public sealed class CombinedPage : TestPage { } + + [UAuthAuthorize(Roles = "User")] + public sealed class UserPage : TestPage { } + + [Authorize(Roles = "Admin")] + public sealed class StandardRolePage : TestPage { } + + [Authorize(Policy = "CanManageUsers")] + public sealed class StandardPolicyPage : TestPage { } + + [Authorize(Roles = "Manager")] + [UAuthAuthorize(Roles = "Admin")] + public sealed class CombinedRolePage : TestPage { } + + [UAuthAuthorize] + public sealed class InitializationProbePage : TestPage + { + public static int Initializations { get; set; } + + protected override void OnInitialized() + => Initializations++; + } +} From c519626d29657d6bb6f9234455adc972d9ffb48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 19 Oct 2026 23:31:47 +0300 Subject: [PATCH 2/4] Central Authorization Evaluator for Client.Blazor --- .../Attributes/UAuthAuthorizeAttribute.cs | 14 +- .../AuthState/IUAuthAuthorizationEvaluator.cs | 10 + .../AuthState/UAuthAuthorizationEvaluator.cs | 75 ++++ .../UAuthAuthorizationRequirement.cs | 35 ++ .../AuthState/UAuthAuthorizationResult.cs | 21 ++ .../Components/Base/UAuthComponentBase.cs | 137 +++---- .../Components/UAuthAuthorizeRouteView.razor | 4 +- .../UAuthAuthorizeRouteView.razor.cs | 98 +++-- .../Components/UAuthStateView.razor | 3 +- .../Components/UAuthStateView.razor.cs | 187 +++------- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Bunit/UAuthStateViewTests.cs | 10 + .../UAuthAuthorizationEvaluatorTests.cs | 217 +++++++++++ .../Blazor/UAuthAuthorizeRouteViewTests.cs | 11 +- .../Client/Blazor/UAuthStateViewTests.cs | 341 +++++++++++++++++- 15 files changed, 869 insertions(+), 296 deletions(-) create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/IUAuthAuthorizationEvaluator.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationEvaluator.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationRequirement.cs create mode 100644 src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationResult.cs create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizationEvaluatorTests.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs index ae253156..c6c144dd 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs @@ -1,4 +1,6 @@ -namespace CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Blazor; /// /// Declares UltimateAuth authorization requirements for a Blazor component or page. @@ -34,4 +36,14 @@ public sealed class UAuthAuthorizeAttribute : Attribute /// permission requirement is declared by this property. /// public string? Permissions { get; set; } + + /// + /// Determines how roles, permissions, and other authorization requirements are combined. + /// + public AuthorizationMatchMode MatchMode { get; set; } = AuthorizationMatchMode.Category; + + /// + /// Determines whether the current session must be active. + /// + public bool RequireActive { get; set; } = true; } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/IUAuthAuthorizationEvaluator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/IUAuthAuthorizationEvaluator.cs new file mode 100644 index 00000000..79ca5a83 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/IUAuthAuthorizationEvaluator.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Client.Blazor; + +/// +/// Evaluates UltimateAuth authorization requirements. +/// Implementations should not manage component lifecycle or rendering. +/// +public interface IUAuthAuthorizationEvaluator +{ + Task EvaluateAsync(UAuthState state, UAuthAuthorizationRequirement requirement); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationEvaluator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationEvaluator.cs new file mode 100644 index 00000000..3a830391 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationEvaluator.cs @@ -0,0 +1,75 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Authorization; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +internal sealed class UAuthAuthorizationEvaluator : IUAuthAuthorizationEvaluator +{ + private readonly IAuthorizationService _authorizationService; + + public UAuthAuthorizationEvaluator(IAuthorizationService authorizationService) + { + _authorizationService = authorizationService; + } + + public async Task EvaluateAsync(UAuthState state, UAuthAuthorizationRequirement requirement) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(requirement); + + if (!state.IsAuthenticated) + return UAuthAuthorizationResult.Denied; + + // Preserve UAuthStateView's existing session-state semantics. + if (requirement.RequireActive && state.Identity?.SessionState is { } sessionState && sessionState != SessionState.Active) + { + return UAuthAuthorizationResult.Inactive; + } + + var roles = ParseCsv(requirement.Roles); + var permissions = ParseCsv(requirement.Permissions); + var hasPolicy = !string.IsNullOrWhiteSpace(requirement.Policy); + + if (roles.Length == 0 && permissions.Length == 0 && !hasPolicy) + { + return UAuthAuthorizationResult.Authorized; + } + + // Capture synchronous results before the first await. + var roleResults = roles.Select(state.IsInRole).ToArray(); + var permissionResults = permissions.Select(state.HasPermission).ToArray(); + bool? policyResult = null; + + if (hasPolicy) + { + var principal = state.ToClaimsPrincipal(); + var result = await _authorizationService.AuthorizeAsync(principal, requirement.Policy!); + policyResult = result.Succeeded; + } + + var authorized = requirement.MatchMode switch + { + AuthorizationMatchMode.Any => roleResults.Any(x => x) || permissionResults.Any(x => x) || policyResult == true, + AuthorizationMatchMode.All => roleResults.All(x => x) && permissionResults.All(x => x) && policyResult != false, + AuthorizationMatchMode.Category => (roleResults.Length == 0 || roleResults.Any(x => x)) && (permissionResults.Length == 0 || permissionResults.Any(x => x)) && policyResult != false, + _ => false + }; + + return authorized + ? UAuthAuthorizationResult.Authorized + : UAuthAuthorizationResult.Denied; + } + + private static string[] ParseCsv(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return []; + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToArray(); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationRequirement.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationRequirement.cs new file mode 100644 index 00000000..10da741f --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationRequirement.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +/// +/// Defines the authorization requirements evaluated by UltimateAuth +/// Blazor authorization components. +/// +public sealed record UAuthAuthorizationRequirement +{ + /// + /// Comma-separated roles. + /// + public string? Roles { get; init; } + + /// + /// Comma-separated permissions. + /// + public string? Permissions { get; init; } + + /// + /// Optional ASP.NET Core authorization policy. + /// + public string? Policy { get; init; } + + /// + /// Determines how authorization conditions are combined. + /// + public AuthorizationMatchMode MatchMode { get; init; } = AuthorizationMatchMode.Category; + + /// + /// Requires an active session when enabled. + /// + public bool RequireActive { get; init; } = true; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationResult.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationResult.cs new file mode 100644 index 00000000..d0c9d234 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthorizationResult.cs @@ -0,0 +1,21 @@ +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public enum UAuthAuthorizationStatus +{ + Authorized = 0, + Denied = 10, + Inactive = 20 +} + +public readonly record struct UAuthAuthorizationResult(UAuthAuthorizationStatus Status) +{ + public bool IsAuthorized => Status == UAuthAuthorizationStatus.Authorized; + + public bool IsInactive => Status == UAuthAuthorizationStatus.Inactive; + + public static UAuthAuthorizationResult Authorized => new(UAuthAuthorizationStatus.Authorized); + + public static UAuthAuthorizationResult Denied => new(UAuthAuthorizationStatus.Denied); + + public static UAuthAuthorizationResult Inactive => new(UAuthAuthorizationStatus.Inactive); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs index d2462d35..59d8b9a8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs @@ -3,15 +3,16 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; /// -/// Base class for Blazor components that participate in UltimateAuth authentication state and authorization lifecycle. +/// Base class for Blazor components that participate in the UltimateAuth authentication state lifecycle. /// public abstract class UAuthComponentBase : ComponentBase, IDisposable { - private UAuthState? _previousState; - private bool _rendered; + private UAuthState? _subscribedState; + private bool _disposed; /// - /// Gets the current UltimateAuth authentication state supplied by UAuthApp. + /// Gets the current UltimateAuth authentication state + /// supplied by UAuthApp. /// [CascadingParameter] protected UAuthState AuthState { get; set; } = default!; @@ -19,131 +20,77 @@ public abstract class UAuthComponentBase : ComponentBase, IDisposable /// /// Gets the Blazor navigation service. /// - [Inject] protected NavigationManager Navigation { get; set; } = default!; + [Inject] + protected NavigationManager Navigation { get; set; } = default!; /// - /// Automatically re-render when UAuthState changes. Can be overridden to disable. + /// Determines whether authentication state changes + /// automatically trigger a component render. /// protected virtual bool AutoRefreshOnAuthStateChanged => true; - /// - /// Called when the component's parameters have been set. This method ensures that the component is properly registered - /// with the current and evaluates authorization requirements. - /// - /// + /// protected override void OnParametersSet() { base.OnParametersSet(); if (AuthState is null) - throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(UAuthState)}. " + - $"Make sure it is used inside ."); - - if (!ReferenceEquals(_previousState, AuthState)) { - if (_previousState is not null) - _previousState.Changed -= OnAuthStateChanged; - - AuthState.Changed += OnAuthStateChanged; - _previousState = AuthState; + throw new InvalidOperationException( + $"{GetType().Name} requires a cascading parameter " + + $"of type {nameof(UAuthState)}. " + + "Make sure it is used inside ."); } - EvaluateAuthorization(); - } - - /// - /// Called after the component has been rendered. This method sets the _rendered flag to true on the first render. - /// - /// - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (firstRender) - { - _rendered = true; - // Never call EvaluateAuthorization() here, because it breaks UAuthAuthorize attribute behavior. - } - } - - private void OnAuthStateChanged(UAuthStateChangeReason reason) - { - HandleAuthStateChanged(reason); - - EvaluateAuthorization(); + if (ReferenceEquals(_subscribedState, AuthState)) + return; - if (AutoRefreshOnAuthStateChanged) - _ = InvokeAsync(StateHasChanged); - } + Unsubscribe(); - /// - /// Allows derived components to react to auth state changes. - /// - protected virtual void HandleAuthStateChanged(UAuthStateChangeReason reason) - { + AuthState.Changed += OnAuthStateChanged; + _subscribedState = AuthState; } - private void EvaluateAuthorization() + private void OnAuthStateChanged(UAuthStateChangeReason reason) { - var attr = GetType() - .GetCustomAttributes(typeof(UAuthAuthorizeAttribute), true) - .FirstOrDefault() as UAuthAuthorizeAttribute; - - if (attr is null) + if (_disposed) return; - if (_rendered && !AuthState.IsAuthenticated) + _ = InvokeAsync(() => { - OnUnauthorized(); - return; - } - - if (_rendered && !string.IsNullOrEmpty(attr.Roles)) - { - var roles = attr.Roles.Split(','); - - if (!roles.Any(r => AuthState.IsInRole(r.Trim()))) - { - OnForbidden(); + if (_disposed) return; - } - } - if (_rendered && !string.IsNullOrEmpty(attr.Permissions)) - { - var permissions = attr.Permissions.Split(','); + HandleAuthStateChanged(reason); - if (!permissions.Any(p => AuthState.HasPermission(p.Trim()))) - { - OnForbidden(); - return; - } - } + if (AutoRefreshOnAuthStateChanged) + StateHasChanged(); + }); } /// - /// Called when the component requires authentication but the current user is not authenticated. + /// Called when the UltimateAuth authentication state changes. /// - protected virtual void OnUnauthorized() + protected virtual void HandleAuthStateChanged(UAuthStateChangeReason reason) { - Navigation.NavigateTo("/"); } - /// - /// Called when the current user is authenticated but does not satisfy the authorization requirements of the component. - /// - protected virtual void OnForbidden() + private void Unsubscribe() { - Navigation.NavigateTo("/forbidden"); + if (_subscribedState is null) + return; + + _subscribedState.Changed -= OnAuthStateChanged; + _subscribedState = null; } - /// - /// Disposes of the component and unsubscribes from the event to prevent memory leaks. - /// + /// public virtual void Dispose() { - if (_previousState is not null) - _previousState.Changed -= OnAuthStateChanged; + if (_disposed) + return; + + _disposed = true; + Unsubscribe(); } -} \ No newline at end of file +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor index 64fd6f69..70d47e4e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor @@ -5,7 +5,7 @@ @implements IDisposable -@if (!_hasUAuthRequirement || (_runtimeReady && _uauthAuthorized)) +@if (!_hasUAuthRequirement || (_runtimeReady && !_evaluating && _uauthAuthorized)) { @@ -29,7 +29,7 @@ } -else if (!_runtimeReady) +else if (!_runtimeReady || _evaluating) { @if (Authorizing is not null) { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs index bd9eef88..62529c21 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Components; +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using System.Reflection; @@ -15,8 +16,13 @@ public partial class UAuthAuthorizeRouteView : ComponentBase, IDisposable private bool _hasUAuthRequirement; private bool _uauthAuthorized; private bool _runtimeReady; + private bool _evaluating; + private long _evaluationVersion; private bool _disposed; + [Inject] + private IUAuthAuthorizationEvaluator AuthorizationEvaluator { get; set; } = default!; + /// /// The route to render. /// @@ -60,75 +66,80 @@ public partial class UAuthAuthorizeRouteView : ComponentBase, IDisposable [CascadingParameter(Name = "UAuthRuntimeReady")] private bool RuntimeReady { get; set; } - private UAuthState AuthState => CurrentAuthState - ?? throw new InvalidOperationException($"{nameof(UAuthAuthorizeRouteView)} requires " + $"a cascading {nameof(UAuthState)}."); + private UAuthState AuthState => CurrentAuthState ?? throw new InvalidOperationException($"{nameof(UAuthAuthorizeRouteView)} requires " + $"a cascading {nameof(UAuthState)}."); - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { - base.OnParametersSet(); + await base.OnParametersSetAsync(); ArgumentNullException.ThrowIfNull(RouteData); - _requirement = RouteData.PageType.GetCustomAttribute(inherit: true); + _requirement = RouteData.PageType + .GetCustomAttribute(inherit: true); _hasUAuthRequirement = _requirement is not null; + _runtimeReady = RuntimeReady; - // Public routes remain under the standard - // ASP.NET Core AuthorizeRouteView. if (!_hasUAuthRequirement) { + ++_evaluationVersion; + _uauthAuthorized = true; + _evaluating = false; + Unsubscribe(); return; } Subscribe(AuthState); - _runtimeReady = RuntimeReady; - - EvaluateAuthorization(); + await EvaluateAuthorizationAsync(); } - private void EvaluateAuthorization() + private async Task EvaluateAuthorizationAsync() { - // Fail closed until UltimateAuth initialization - // has completed. + var version = ++_evaluationVersion; + _uauthAuthorized = false; + _evaluating = false; - if (!_runtimeReady || _requirement is null) + if (_disposed || !_runtimeReady || _requirement is null) return; - if (!AuthState.IsAuthenticated) - return; + var requirement = CreateRequirement(_requirement); + + var state = AuthState; - var roles = ParseValues(_requirement.Roles); - var permissions = ParseValues(_requirement.Permissions); + _evaluating = true; - // Category semantics: - // At least one role AND at least one permission, - // when both categories are configured. - if (roles.Length > 0 && !roles.Any(AuthState.IsInRole)) + try { - return; - } + var result = await AuthorizationEvaluator.EvaluateAsync(state, requirement); - if (permissions.Length > 0 && !permissions.Any(AuthState.HasPermission)) + if (_disposed || version != _evaluationVersion) + return; + + _uauthAuthorized = result.IsAuthorized; + } + finally { - return; + if (!_disposed && version == _evaluationVersion) + { + _evaluating = false; + await InvokeAsync(StateHasChanged); + } } - - _uauthAuthorized = true; } - private static string[] ParseValues(string? values) + private UAuthAuthorizationRequirement CreateRequirement(UAuthAuthorizeAttribute attribute) { - if (string.IsNullOrWhiteSpace(values)) - return []; - - return values - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .ToArray(); + return new UAuthAuthorizationRequirement + { + Roles = attribute.Roles, + Permissions = attribute.Permissions, + MatchMode = attribute.MatchMode, + RequireActive = attribute.RequireActive + }; } private void Subscribe(UAuthState state) @@ -156,19 +167,26 @@ private void OnAuthStateChanged(UAuthStateChangeReason reason) if (_disposed) return; - _ = InvokeAsync(() => + _ = InvokeAsync(async () => { if (_disposed) return; - EvaluateAuthorization(); - StateHasChanged(); + await EvaluateAuthorizationAsync(); + + if (!_disposed) + StateHasChanged(); }); } public void Dispose() { + if (_disposed) + return; + _disposed = true; + ++_evaluationVersion; + Unsubscribe(); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor index 1f9227b4..3a30e7dd 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor @@ -1,8 +1,7 @@ @namespace CodeBeam.UltimateAuth.Client.Blazor @inherits UAuthComponentBase -@using Microsoft.AspNetCore.Authorization -@inject IAuthorizationService AuthorizationService +@inject IUAuthAuthorizationEvaluator AuthorizationEvaluator @if (_inactive) { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index bdce111b..9b913214 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -10,14 +10,14 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; /// public partial class UAuthStateView : UAuthComponentBase { - private IReadOnlyList _rolesParsed = Array.Empty(); - private IReadOnlyList _permissionsParsed = Array.Empty(); - private bool _authorized; - private bool _inactive; + private UAuthAuthorizationResult _result = UAuthAuthorizationResult.Denied; + private bool _authorizing; - private string? _authKey; - private string? _rolesRaw; - private string? _permissionsRaw; + private long _evaluationVersion; + private UAuthAuthorizationRequirement? _currentRequirement; + + private bool _authorized => _result.IsAuthorized; + private bool _inactive => _result.IsInactive; /// /// Gets or sets the content to render when the user is authorized. The content receives the current as a parameter. @@ -104,29 +104,14 @@ protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); - var newKey = BuildAuthKey(); + var requirement = CreateRequirement(); - if (_authKey == newKey) + if (_currentRequirement == requirement) return; - _authKey = newKey; - _authorizing = true; - - if (_rolesRaw != Roles) - { - _rolesRaw = Roles; - _rolesParsed = ParseCsv(Roles); - } - - if (_permissionsRaw != Permissions) - { - _permissionsRaw = Permissions; - _permissionsParsed = ParseCsv(Permissions); - } + _currentRequirement = requirement; - EvaluateSessionState(); - _authorized = await EvaluateAuthorizationAsync(); - _authorizing = false; + await ReevaluateAuthorizationAsync(requirement); } /// @@ -136,139 +121,55 @@ protected override async Task OnParametersSetAsync() /// protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason) { - EvaluateSessionState(); - _authorizing = true; - _authorized = await EvaluateAuthorizationAsync(); - _authorizing = false; - await InvokeAsync(StateHasChanged); - } - - private async Task EvaluateAuthorizationAsync() - { - if (!AuthState.IsAuthenticated) - return false; - - var hasRoles = _rolesParsed.Count > 0; - var hasPermissions = _permissionsParsed.Count > 0; - var hasPolicy = !string.IsNullOrWhiteSpace(Policy); - - // No explicit authorization requirements: - // authentication itself is sufficient. - if (!hasRoles && !hasPermissions && !hasPolicy) - return true; - - var roleResults = _rolesParsed - .Select(AuthState.IsInRole) - .ToList(); - - var permissionResults = _permissionsParsed - .Select(AuthState.HasPermission) - .ToList(); - - bool? policyResult = null; - - if (!string.IsNullOrWhiteSpace(Policy)) + try { - policyResult = await EvaluatePolicyAsync(); + await InvokeAsync(async () => + { + var requirement = _currentRequirement ?? CreateRequirement(); + await ReevaluateAuthorizationAsync(requirement); + }); } - - return MatchMode switch + catch (ObjectDisposedException) { - AuthorizationMatchMode.Any - => EvaluateAny(roleResults, permissionResults, policyResult), - - AuthorizationMatchMode.All - => EvaluateAll(roleResults, permissionResults, policyResult), - - AuthorizationMatchMode.Category - => EvaluateCategory(roleResults, permissionResults, policyResult), - - _ => false - }; - } - - private static bool EvaluateAny(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) - { - return roles.Any(x => x) || permissions.Any(x => x) || policy == true; - } - - private static bool EvaluateAll(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) - { - if (roles.Count > 0 && roles.Any(x => !x)) - return false; - - if (permissions.Count > 0 && permissions.Any(x => !x)) - return false; - - if (policy.HasValue && !policy.Value) - return false; - - return true; + // The component was disposed during evaluation. + } } - private static bool EvaluateCategory(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + private async Task ReevaluateAuthorizationAsync(UAuthAuthorizationRequirement requirement) { - if (roles.Count > 0 && !roles.Any(x => x)) - return false; - - if (permissions.Count > 0 && !permissions.Any(x => x)) - return false; + var version = ++_evaluationVersion; - if (policy.HasValue && !policy.Value) - return false; - - return true; - } + _authorizing = true; + _result = UAuthAuthorizationResult.Denied; - private void EvaluateSessionState() - { - if (!RequireActive) + try { - _inactive = false; - return; - } + var result = await AuthorizationEvaluator.EvaluateAsync(AuthState, requirement); - if (AuthState.IsAuthenticated != true) - { - _inactive = false; - return; - } + if (version != _evaluationVersion) + return; - if (AuthState.Identity?.SessionState is null) - { - _inactive = false; + _result = result; } - else + finally { - _inactive = AuthState.Identity?.SessionState != SessionState.Active; + if (version == _evaluationVersion) + { + _authorizing = false; + await InvokeAsync(StateHasChanged); + } } } - private static IReadOnlyList ParseCsv(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return Array.Empty(); - - return value - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .Where(x => x.Length > 0) - .ToArray(); - } - - private async Task EvaluatePolicyAsync() + private UAuthAuthorizationRequirement CreateRequirement() { - if (string.IsNullOrWhiteSpace(Policy)) - return true; - - var principal = AuthState.ToClaimsPrincipal(); - var result = await AuthorizationService.AuthorizeAsync(principal, Policy); - - return result.Succeeded; - } - - private string BuildAuthKey() - { - return $"{Roles}|{Permissions}|{Policy}|{MatchMode}{RequireActive}"; + return new UAuthAuthorizationRequirement + { + Roles = Roles, + Permissions = Permissions, + Policy = Policy, + MatchMode = MatchMode, + RequireActive = RequireActive + }; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs index 17d6ea68..2191e695 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -41,6 +41,8 @@ private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServ { services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.AddScoped(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs index 0b046015..bed481a7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs @@ -33,6 +33,7 @@ public void Should_Render_NotAuthorized_When_Not_Authenticated() using var ctx = new BunitContext(); var state = UAuthState.Anonymous(); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = ctx.Render>(parameters => parameters .Add(p => p.Value, state) @@ -50,6 +51,7 @@ public void Should_Render_ChildContent_When_Authorized_Without_Conditions() using var ctx = new BunitContext(); var state = TestAuthState.Authenticated(); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.ChildContent, s => b => b.AddContent(0, "authorized")) @@ -64,6 +66,7 @@ public void Should_Render_NotAuthorized_When_Role_Not_Match() using var ctx = new BunitContext(); var state = TestAuthState.WithRoles("user"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin") @@ -79,6 +82,7 @@ public void Should_Render_Authorized_When_Role_Matches() using var ctx = new BunitContext(); var state = TestAuthState.WithRoles("admin"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin") @@ -94,6 +98,7 @@ public void Should_Check_Permissions() using var ctx = new BunitContext(); var state = TestAuthState.WithPermissions("read"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Permissions, "write") @@ -109,6 +114,7 @@ public void Should_Require_All_When_MatchAll_True() using var ctx = new BunitContext(); var state = TestAuthState.WithRoles("admin"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") @@ -125,6 +131,7 @@ public void Should_Allow_Any_When_MatchAll_False() using var ctx = new BunitContext(); var state = TestAuthState.WithRoles("admin"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") @@ -143,6 +150,7 @@ public void Should_Fail_When_One_Category_Does_Not_Match_In_Category_Mode() var state = TestAuthState.WithRoles("admin"); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") @@ -164,6 +172,7 @@ public void Should_Require_At_Least_One_Match_Per_Category_When_MatchMode_Is_Cat permissions: ["write"]); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") @@ -181,6 +190,7 @@ public void Should_Render_Inactive_When_Session_Not_Active() using var ctx = new BunitContext(); var state = TestAuthState.WithSession(SessionState.Revoked); ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddScoped(); var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Inactive, s => b => b.AddContent(0, "inactive")) diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizationEvaluatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizationEvaluatorTests.cs new file mode 100644 index 00000000..25ae3095 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizationEvaluatorTests.cs @@ -0,0 +1,217 @@ +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Authorization; +using Moq; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthAuthorizationEvaluatorTests +{ + private readonly Mock _authorization = new(); + + private UAuthAuthorizationEvaluator CreateEvaluator() => new(_authorization.Object); + + [Fact] + public async Task AnonymousUser_IsDenied() + { + var result = await CreateEvaluator().EvaluateAsync(TestAuthState.Anonymous(), new UAuthAuthorizationRequirement()); + result.Status.Should().Be(UAuthAuthorizationStatus.Denied); + } + + [Fact] + public async Task AuthenticatedUser_WithoutRequirements_IsAuthorized() + { + var result = await CreateEvaluator().EvaluateAsync(TestAuthState.Authenticated(), new UAuthAuthorizationRequirement()); + result.IsAuthorized.Should().BeTrue(); + } + + [Fact] + public async Task Category_RequiresRoleAndPermission() + { + var requirement = new UAuthAuthorizationRequirement + { + Roles = "Admin,Manager", + Permissions = "users.read,users.write" + }; + + var authorized = await CreateEvaluator().EvaluateAsync( + TestAuthState.Create( + roles: ["Manager"], + permissions: ["users.read"]), + requirement); + + var denied = await CreateEvaluator().EvaluateAsync( + TestAuthState.Create( + roles: ["Manager"], + permissions: ["users.list"]), + requirement); + + authorized.IsAuthorized.Should().BeTrue(); + denied.IsAuthorized.Should().BeFalse(); + } + + [Fact] + public async Task Csv_IgnoresWhitespaceAndEmptyValues() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.WithRoles("Admin"), + new UAuthAuthorizationRequirement + { + Roles = " User, , Admin, " + }); + + result.IsAuthorized.Should().BeTrue(); + } + + [Fact] + public async Task Any_AllowsMatchingPermissionWhenRoleFails() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.Create( + roles: ["User"], + permissions: ["users.read"]), + new UAuthAuthorizationRequirement + { + Roles = "Admin", + Permissions = "users.read", + MatchMode = AuthorizationMatchMode.Any + }); + + result.IsAuthorized.Should().BeTrue(); + } + + [Fact] + public async Task All_RequiresEveryRoleAndPermission() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.Create( + roles: ["Admin"], + permissions: ["users.read"]), + new UAuthAuthorizationRequirement + { + Roles = "Admin,Manager", + Permissions = "users.read", + MatchMode = AuthorizationMatchMode.All + }); + + result.Status.Should().Be(UAuthAuthorizationStatus.Denied); + } + + [Fact] + public async Task RevokedSession_WhenRequireActive_ReturnsInactive() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.WithSession(SessionState.Revoked), + new UAuthAuthorizationRequirement + { + RequireActive = true + }); + + result.Status.Should().Be(UAuthAuthorizationStatus.Inactive); + } + + [Fact] + public async Task RevokedSession_WhenActiveNotRequired_CanAuthorize() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.WithSession(SessionState.Revoked), + new UAuthAuthorizationRequirement + { + RequireActive = false + }); + + result.IsAuthorized.Should().BeTrue(); + } + + [Fact] + public async Task SuccessfulPolicy_Authorizes() + { + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "CanManageUsers")) + .ReturnsAsync(AuthorizationResult.Success()); + + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.Authenticated(), + new UAuthAuthorizationRequirement + { + Policy = "CanManageUsers" + }); + + result.IsAuthorized.Should().BeTrue(); + } + + [Fact] + public async Task FailedPolicy_Denies() + { + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "CanManageUsers")) + .ReturnsAsync(AuthorizationResult.Failed()); + + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.Authenticated(), + new UAuthAuthorizationRequirement + { + Policy = "CanManageUsers" + }); + + result.Status.Should().Be(UAuthAuthorizationStatus.Denied); + } + + [Fact] + public async Task InactiveSession_DoesNotEvaluatePolicy() + { + var result = await CreateEvaluator().EvaluateAsync( + TestAuthState.WithSession(SessionState.Revoked), + new UAuthAuthorizationRequirement + { + Policy = "CanManageUsers", + RequireActive = true + }); + + result.IsInactive.Should().BeTrue(); + + _authorization.Verify(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task Policy_ReceivesClaimsFromUAuthState() + { + ClaimsPrincipal? captured = null; + + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "CanManageUsers")) + .Callback( + (principal, _, _) => captured = principal) + .ReturnsAsync(AuthorizationResult.Success()); + + await CreateEvaluator().EvaluateAsync( + TestAuthState.Create( + roles: ["Admin"], + permissions: ["users.read"]), + new UAuthAuthorizationRequirement + { + Policy = "CanManageUsers" + }); + + captured.Should().NotBeNull(); + captured!.IsInRole("Admin").Should().BeTrue(); + captured.HasClaim( + "uauth:permission", "users.read").Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs index 66c393d3..4d65251f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs @@ -1,14 +1,15 @@ -using Bunit; +using AwesomeAssertions; +using Bunit; +using Bunit.TestDoubles; using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; -using AwesomeAssertions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Rendering; -using Bunit.TestDoubles; -using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.Extensions.DependencyInjection; namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; @@ -25,6 +26,8 @@ public sealed class UAuthAuthorizeRouteViewTests : BunitContext public UAuthAuthorizeRouteViewTests() { + Services.AddScoped(); + _authorization = AddAuthorization(); _authorization.SetNotAuthorized(); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs index f1a6819c..5d085159 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs @@ -1,10 +1,11 @@ -using Bunit; +using AwesomeAssertions; +using Bunit; using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using AwesomeAssertions; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; @@ -25,6 +26,7 @@ public sealed class UAuthStateViewTests : BunitContext public UAuthStateViewTests() { Services.AddSingleton(_authorization.Object); + Services.AddScoped(); } // ============================================================ @@ -528,6 +530,260 @@ public void ChangingRequireActive_ReevaluatesState() cut.Markup.Should().NotContain(InactiveContent); } + [Fact] + public void Login_AfterAnonymousState_RendersAuthorized() + { + var state = UAuthState.Anonymous(); + var cut = RenderView(state); + + AssertDenied(cut.Markup); + + var authenticated = TestAuthState.Authenticated(); + + cut.InvokeAsync(() => state.ApplySnapshot( + new AuthStateSnapshot + { + Identity = authenticated.Identity + ?? throw new InvalidOperationException("Expected identity."), + Claims = authenticated.Claims + }, + DateTimeOffset.UtcNow)); + + cut.WaitForAssertion(() => AssertAuthorized(cut.Markup)); + } + + [Fact] + public void Logout_AfterAuthenticatedState_RendersNotAuthorized() + { + var state = AuthenticatedState(); + var cut = RenderView(state); + + AssertAuthorized(cut.Markup); + + cut.InvokeAsync(state.Clear); + + cut.WaitForAssertion(() => AssertDenied(cut.Markup)); + } + + [Fact] + public void RoleRevocation_RemovesAuthorizedSection() + { + var state = AuthenticatedState(roles: ["Admin"]); + var cut = RenderView(state, roles: "Admin"); + + AssertAuthorized(cut.Markup); + + var replacement = AuthenticatedState(roles: ["User"]); + + cut.InvokeAsync(() => ApplyState(state, replacement)); + + cut.WaitForAssertion(() => AssertDenied(cut.Markup)); + } + + [Fact] + public void PermissionRevocation_RemovesAuthorizedSection() + { + var state = AuthenticatedState(permissions: ["users.read"]); + var cut = RenderView(state, permissions: "users.read"); + + AssertAuthorized(cut.Markup); + + var replacement = AuthenticatedState(permissions: ["users.list"]); + + cut.InvokeAsync(() => ApplyState(state, replacement)); + + cut.WaitForAssertion(() => AssertDenied(cut.Markup)); + } + + [Fact] + public void SessionRevocation_RendersInactive() + { + var state = AuthenticatedState(sessionState: SessionState.Active); + var cut = RenderView(state, requireActive: true); + + AssertAuthorized(cut.Markup); + + var replacement = AuthenticatedState( + sessionState: SessionState.Revoked); + + cut.InvokeAsync(() => ApplyState(state, replacement)); + + cut.WaitForAssertion(() => AssertInactive(cut.Markup)); + } + + [Fact] + public void SessionReactivation_RendersAuthorized() + { + var state = AuthenticatedState(sessionState: SessionState.Revoked); + var cut = RenderView(state, requireActive: true); + + AssertInactive(cut.Markup); + + var replacement = AuthenticatedState( + sessionState: SessionState.Active); + + cut.InvokeAsync(() => ApplyState(state, replacement)); + + cut.WaitForAssertion(() => AssertAuthorized(cut.Markup)); + } + + [Fact] + public void ChangingRoles_ReevaluatesAuthorization() + { + var state = AuthenticatedState(roles: ["Admin"]); + + var cut = Render(p => p + .Add(x => x.State, state) + .Add(x => x.Roles, "Admin")); + + AssertAuthorized(cut.Markup); + + cut.Render(p => p + .Add(x => x.State, state) + .Add(x => x.Roles, "Manager")); + + AssertDenied(cut.Markup); + } + + [Fact] + public void ChangingPermissions_ReevaluatesAuthorization() + { + var state = AuthenticatedState( + permissions: ["users.read"]); + + var cut = Render(p => p + .Add(x => x.State, state) + .Add(x => x.Permissions, "users.read")); + + AssertAuthorized(cut.Markup); + + cut.Render(p => p + .Add(x => x.State, state) + .Add(x => x.Permissions, "users.write")); + + AssertDenied(cut.Markup); + } + + [Fact] + public void ChangingMatchMode_ReevaluatesAuthorization() + { + var state = AuthenticatedState(roles: ["Admin"]); + + var cut = Render(p => p + .Add(x => x.State, state) + .Add(x => x.Roles, "Admin,Manager") + .Add(x => x.MatchMode, AuthorizationMatchMode.Category)); + + AssertAuthorized(cut.Markup); + + cut.Render(p => p + .Add(x => x.State, state) + .Add(x => x.Roles, "Admin,Manager") + .Add(x => x.MatchMode, AuthorizationMatchMode.All)); + + AssertDenied(cut.Markup); + } + + [Fact] + public void ChangingPolicy_ReevaluatesAuthorization() + { + var state = AuthenticatedState(); + + SetupPolicy("PolicyA", AuthorizationResult.Success()); + SetupPolicy("PolicyB", AuthorizationResult.Failed()); + + var cut = Render(p => p + .Add(x => x.State, state) + .Add(x => x.Policy, "PolicyA")); + + AssertAuthorized(cut.Markup); + + cut.Render(p => p + .Add(x => x.State, state) + .Add(x => x.Policy, "PolicyB")); + + AssertDenied(cut.Markup); + } + + [Fact] + public void PendingPolicy_DoesNotRenderAuthorizedContent() + { + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "SlowPolicy")) + .Returns(pending.Task); + + var cut = Render(p => p + .Add(x => x.State, AuthenticatedState()) + .Add(x => x.Policy, "SlowPolicy")); + + cut.Markup.Should().NotContain(AuthorizedContent); + + pending.SetResult(AuthorizationResult.Success()); + + cut.WaitForAssertion(() => AssertAuthorized(cut.Markup)); + } + + [Fact] + public void StalePolicyResult_MustNotOverrideNewerAuthorization() + { + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "SlowPolicy")) + .Returns(pending.Task); + + SetupPolicy("FastPolicy", AuthorizationResult.Failed()); + + var state = AuthenticatedState(); + + var cut = Render(p => p + .Add(x => x.State, state) + .Add(x => x.Policy, "SlowPolicy")); + + cut.Render(p => p + .Add(x => x.State, state) + .Add(x => x.Policy, "FastPolicy")); + + AssertDenied(cut.Markup); + + pending.SetResult(AuthorizationResult.Success()); + + cut.WaitForAssertion(() => AssertDenied(cut.Markup)); + } + + [Fact] + public void CustomEvaluator_IsUsedByStateView() + { + var custom = new Mock(); + + custom + .Setup(x => x.EvaluateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(UAuthAuthorizationResult.Denied); + + Services.AddSingleton(custom.Object); + + var cut = RenderView(AuthenticatedState()); + + AssertDenied(cut.Markup); + + custom.Verify(x => x.EvaluateAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + } + // ============================================================ // Helpers // ============================================================ @@ -607,9 +863,7 @@ private IRenderedComponent> RenderView( })); } - private void SetupPolicy( - string policy, - AuthorizationResult result) + private void SetupPolicy(string policy, AuthorizationResult result) { _authorization .Setup(x => x.AuthorizeAsync( @@ -619,6 +873,17 @@ private void SetupPolicy( .ReturnsAsync(result); } + private static void ApplyState(UAuthState target, UAuthState source) + { + target.ApplySnapshot( + new AuthStateSnapshot + { + Identity = source.Identity ?? throw new InvalidOperationException("Expected authenticated identity."), + Claims = source.Claims + }, + DateTimeOffset.UtcNow); + } + private static void AssertAuthorized(string markup) { markup.Should().Contain(AuthorizedContent); @@ -668,10 +933,7 @@ private static UAuthState AuthenticatedState( }; var state = UAuthState.Anonymous(); - - state.ApplySnapshot( - snapshot, - DateTimeOffset.UtcNow); + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); return state; } @@ -730,4 +992,65 @@ protected override void BuildRenderTree( builder.CloseComponent(); } } + + private sealed class ConfigurableStateViewHost : ComponentBase + { + [Parameter] public UAuthState State { get; set; } = default!; + [Parameter] public string? Roles { get; set; } + [Parameter] public string? Permissions { get; set; } + [Parameter] public string? Policy { get; set; } + + [Parameter] + public AuthorizationMatchMode MatchMode { get; set; } + = AuthorizationMatchMode.Category; + + [Parameter] + public bool RequireActive { get; set; } = true; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", State); + + builder.AddAttribute(2, "ChildContent", + (RenderFragment)(content => + { + content.OpenComponent(0); + + content.AddAttribute(1, nameof(UAuthStateView.Roles), Roles); + content.AddAttribute(2, nameof(UAuthStateView.Permissions), Permissions); + content.AddAttribute(3, nameof(UAuthStateView.Policy), Policy); + content.AddAttribute(4, nameof(UAuthStateView.MatchMode), MatchMode); + content.AddAttribute(5, nameof(UAuthStateView.RequireActive), RequireActive); + + content.AddAttribute( + 6, + nameof(UAuthStateView.Authorized), + (RenderFragment)(_ => child => + child.AddContent(0, AuthorizedContent))); + + content.AddAttribute( + 7, + nameof(UAuthStateView.NotAuthorized), + (RenderFragment)(child => + child.AddContent(0, DeniedContent))); + + content.AddAttribute( + 8, + nameof(UAuthStateView.Inactive), + (RenderFragment)(_ => child => + child.AddContent(0, InactiveContent))); + + content.AddAttribute( + 9, + nameof(UAuthStateView.Authorizing), + (RenderFragment)(child => + child.AddContent(0, "AUTHORIZING-CONTENT"))); + + content.CloseComponent(); + })); + + builder.CloseComponent(); + } + } } \ No newline at end of file From ea99bd2f387b6a20f7cd1632b7f82848f5f38dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 19 Oct 2026 23:56:01 +0300 Subject: [PATCH 3/4] Fix Tests --- .../Components/Base/UAuthComponentBase.cs | 59 ++- .../Components/UAuthStateView.razor.cs | 46 +- .../Client/Blazor/UAuthComponentBaseTests.cs | 434 +++++++++--------- 3 files changed, 288 insertions(+), 251 deletions(-) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs index 59d8b9a8..179111a7 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs @@ -3,33 +3,30 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; /// -/// Base class for Blazor components that participate in the UltimateAuth authentication state lifecycle. +/// Base class for Blazor components that participate in +/// the UltimateAuth authentication state lifecycle. /// public abstract class UAuthComponentBase : ComponentBase, IDisposable { private UAuthState? _subscribedState; private bool _disposed; - /// - /// Gets the current UltimateAuth authentication state - /// supplied by UAuthApp. - /// [CascadingParameter] protected UAuthState AuthState { get; set; } = default!; - /// - /// Gets the Blazor navigation service. - /// [Inject] protected NavigationManager Navigation { get; set; } = default!; /// - /// Determines whether authentication state changes - /// automatically trigger a component render. + /// Automatically re-render when the authentication state changes. /// protected virtual bool AutoRefreshOnAuthStateChanged => true; - /// + /// + /// Indicates whether the component has been disposed. + /// + protected bool IsDisposed => _disposed; + protected override void OnParametersSet() { base.OnParametersSet(); @@ -47,8 +44,8 @@ protected override void OnParametersSet() Unsubscribe(); - AuthState.Changed += OnAuthStateChanged; _subscribedState = AuthState; + _subscribedState.Changed += OnAuthStateChanged; } private void OnAuthStateChanged(UAuthStateChangeReason reason) @@ -56,23 +53,42 @@ private void OnAuthStateChanged(UAuthStateChangeReason reason) if (_disposed) return; - _ = InvokeAsync(() => + _ = DispatchAuthStateChangedAsync(reason); + } + + private async Task DispatchAuthStateChangedAsync(UAuthStateChangeReason reason) + { + try { - if (_disposed) - return; + await InvokeAsync(async () => + { + if (_disposed) + return; - HandleAuthStateChanged(reason); + await HandleAuthStateChangedAsync(reason); - if (AutoRefreshOnAuthStateChanged) - StateHasChanged(); - }); + if (!_disposed && AutoRefreshOnAuthStateChanged) + StateHasChanged(); + }); + } + catch (ObjectDisposedException) when (_disposed) + { + // Ignore disposal-related exceptions. + } + catch (Exception ex) + { + if (!_disposed) + await DispatchExceptionAsync(ex); + } } /// - /// Called when the UltimateAuth authentication state changes. + /// Called when the authentication state changes. + /// Override to perform asynchronous state-dependent work. /// - protected virtual void HandleAuthStateChanged(UAuthStateChangeReason reason) + protected virtual Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason) { + return Task.CompletedTask; } private void Unsubscribe() @@ -84,7 +100,6 @@ private void Unsubscribe() _subscribedState = null; } - /// public virtual void Dispose() { if (_disposed) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index 9b913214..f25601e1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -13,6 +13,7 @@ public partial class UAuthStateView : UAuthComponentBase private UAuthAuthorizationResult _result = UAuthAuthorizationResult.Denied; private bool _authorizing; + private UAuthState? _evaluatedState; private long _evaluationVersion; private UAuthAuthorizationRequirement? _currentRequirement; @@ -106,11 +107,13 @@ protected override async Task OnParametersSetAsync() var requirement = CreateRequirement(); - if (_currentRequirement == requirement) + if (_currentRequirement == requirement && ReferenceEquals(_evaluatedState, AuthState)) + { return; + } _currentRequirement = requirement; - + _evaluatedState = AuthState; await ReevaluateAuthorizationAsync(requirement); } @@ -119,41 +122,48 @@ protected override async Task OnParametersSetAsync() /// This method is called when the authentication state changes, and it evaluates the current session state and authorization requirements. /// /// - protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason) + protected override async Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason) { - try - { - await InvokeAsync(async () => - { - var requirement = _currentRequirement ?? CreateRequirement(); - await ReevaluateAuthorizationAsync(requirement); - }); - } - catch (ObjectDisposedException) - { - // The component was disposed during evaluation. - } + if (IsDisposed) + return; + + var requirement = _currentRequirement ?? CreateRequirement(); + await ReevaluateAuthorizationAsync(requirement); } private async Task ReevaluateAuthorizationAsync(UAuthAuthorizationRequirement requirement) { + if (IsDisposed) + return; + var version = ++_evaluationVersion; _authorizing = true; _result = UAuthAuthorizationResult.Denied; + // Display Authorizing immediately and remove previously + // authorized content before awaiting policy evaluation. + await InvokeAsync(StateHasChanged); + try { - var result = await AuthorizationEvaluator.EvaluateAsync(AuthState, requirement); + var result = await AuthorizationEvaluator.EvaluateAsync( + AuthState, + requirement); - if (version != _evaluationVersion) + if (IsDisposed || version != _evaluationVersion) return; _result = result; } + catch (Exception) when (!IsDisposed && version == _evaluationVersion) + { + _result = UAuthAuthorizationResult.Denied; + throw; + } finally { - if (version == _evaluationVersion) + if (!IsDisposed && version == _evaluationVersion) { _authorizing = false; await InvokeAsync(StateHasChanged); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs index 40dd863a..bf4f18c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs @@ -1,13 +1,12 @@ -using Bunit; +using AwesomeAssertions; +using Bunit; using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using AwesomeAssertions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; @@ -26,283 +25,275 @@ public void Render_WithoutUAuthState_ThrowsInvalidOperationException() [Fact] public void Render_WithUAuthState_ReceivesCascadingState() { - var state = UAuthState.Anonymous(); + var state = AuthenticatedState(); var cut = RenderWithState(state); + var component = GetComponent(cut); - var component = cut - .FindComponent() - .Instance; - - component.CurrentAuthState - .Should() - .BeSameAs(state); + component.CurrentAuthState.Should().BeSameAs(state); } [Fact] - public void Render_WithoutAuthorizeAttribute_DoesNotCallUnauthorized() + public void InitialRender_DoesNotInvokeStateChangedHandler() { - var state = UAuthState.Anonymous(); + var state = AuthenticatedState(); var cut = RenderWithState(state); + var component = GetComponent(cut); - var component = cut - .FindComponent() - .Instance; - - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); - } - - [Fact] - public void AuthStateCleared_WithRoleRequirement_WhenAnonymous_CallsUnauthorized_NotForbidden() - { - var state = UAuthState.Anonymous(); - - var cut = RenderWithState(state); - - var component = cut - .FindComponent() - .Instance; - - // Initial render must not make a premature authorization decision. - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); - - state.Clear(); - - component.UnauthorizedCount.Should().Be(1); - component.ForbiddenCount.Should().Be(0); + component.StateChangedCount.Should().Be(0); + component.LastChangeReason.Should().BeNull(); + component.RenderCount.Should().Be(1); } [Fact] - public void Dispose_DoesNotThrow() + public void StateChange_ForwardsReasonToComponent() { - var state = UAuthState.Anonymous(); + var state = AuthenticatedState(); var cut = RenderWithState(state); + var component = GetComponent(cut); - var act = () => cut.Dispose(); + state.MarkStale(); - act.Should().NotThrow(); + cut.WaitForAssertion(() => + { + component.StateChangedCount.Should().Be(1); + component.LastChangeReason + .Should().Be(UAuthStateChangeReason.MarkedStale); + }); } [Fact] - public void Render_WithAuthorizeAttribute_WhenAuthenticated_DoesNotReject() + public void StateChange_TriggersAutomaticRender() { var state = AuthenticatedState(); - var cut = RenderWithState(state); - - var component = cut - .FindComponent() - .Instance; - - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); - } - - [Fact] - public void Render_WithRequiredRole_WhenUserHasRole_DoesNotReject() - { - var state = AuthenticatedState( - roles: ["admin"]); + var cut = RenderWithState(state); + var component = GetComponent(cut); - var cut = RenderWithState(state); + var initialRenderCount = component.RenderCount; - var component = cut - .FindComponent() - .Instance; + state.MarkStale(); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + cut.WaitForAssertion(() => + { + component.StateChangedCount.Should().Be(1); + component.RenderCount.Should().BeGreaterThan(initialRenderCount); + }); } [Fact] - public void AuthStateChanged_WithRequiredRole_WhenUserDoesNotHaveRole_CallsForbidden() + public void StateChange_WhenAutoRefreshDisabled_DoesNotRender() { - var state = AuthenticatedState( - roles: ["User"]); - - var cut = RenderWithState(state); + var state = AuthenticatedState(); - var component = cut - .FindComponent() - .Instance; + var cut = RenderWithState(state); + var component = GetComponent(cut); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + var initialRenderCount = component.RenderCount; state.MarkStale(); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(1); + cut.WaitForAssertion(() => + { + component.StateChangedCount.Should().Be(1); + }); + + component.RenderCount.Should().Be(initialRenderCount); } [Fact] - public void Render_WithRequiredPermission_WhenUserHasPermission_DoesNotReject() + public async Task StateChange_AwaitsAsyncHandlerBeforeRendering() { - var state = AuthenticatedState( - permissions: ["users.read"]); - - var cut = RenderWithState(state); - - var component = cut - .FindComponent() - .Instance; + var state = AuthenticatedState(); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); - } + var cut = RenderWithState(state); + var component = GetComponent(cut); - [Fact] - public void AuthStateChanged_WithRequiredPermission_WhenUserDoesNotHavePermission_CallsForbidden() - { - var state = AuthenticatedState( - permissions: ["users.list"]); + var initialRenderCount = component.RenderCount; - var cut = RenderWithState(state); + state.MarkStale(); - var component = cut - .FindComponent() - .Instance; + cut.WaitForAssertion(() => + { + component.HandlerStarted.Should().BeTrue(); + }); - // No premature decision during initial render. - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + component.HandlerCompleted.Should().BeFalse(); + component.RenderCount.Should().Be(initialRenderCount); - state.MarkStale(); + await cut.InvokeAsync(() => + component.CompleteHandler()); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(1); + cut.WaitForAssertion(() => + { + component.HandlerCompleted.Should().BeTrue(); + component.RenderCount.Should().BeGreaterThan(initialRenderCount); + }); } [Fact] - public void Render_WithMultipleRoles_AllowsWhenAnyRoleMatches() + public void Dispose_DoesNotThrow() { - var state = AuthenticatedState( - roles: ["manager"]); + var state = AuthenticatedState(); - var cut = RenderWithState(state); + var cut = RenderWithState(state); - var component = cut - .FindComponent() - .Instance; + var act = () => cut.Dispose(); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + act.Should().NotThrow(); } [Fact] - public void Render_WithMultiplePermissions_AllowsWhenAnyPermissionMatches() + public void Dispose_CanBeCalledMultipleTimes() { - var state = AuthenticatedState( - permissions: ["users.write"]); + var state = AuthenticatedState(); - var cut = RenderWithState(state); + var cut = RenderWithState(state); + var component = GetComponent(cut); - var component = cut - .FindComponent() - .Instance; + var act = () => + { + component.Dispose(); + component.Dispose(); + cut.Dispose(); + }; - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + act.Should().NotThrow(); + component.Disposed.Should().BeTrue(); } [Fact] - public void StateChange_ForwardsReasonToComponent() + public void Dispose_UnsubscribesFromAuthState() { var state = AuthenticatedState(); var cut = RenderWithState(state); - - var component = cut - .FindComponent() - .Instance; + var component = GetComponent(cut); state.MarkStale(); + cut.WaitForAssertion(() => + { + component.StateChangedCount.Should().Be(1); + }); + + component.Dispose(); + + state.MarkValidated(DateTimeOffset.UtcNow); + component.StateChangedCount.Should().Be(1); component.LastChangeReason .Should().Be(UAuthStateChangeReason.MarkedStale); } [Fact] - public void StateChange_ReevaluatesAuthorization() + public void Dispose_DuringPendingHandler_DoesNotRenderAfterCompletion() { var state = AuthenticatedState(); - var cut = - RenderWithState(state); + var cut = RenderWithState(state); + var component = GetComponent(cut); + + state.MarkStale(); + + cut.WaitForAssertion(() => + { + component.HandlerStarted.Should().BeTrue(); + }); + + component.Dispose(); - var component = cut - .FindComponent() - .Instance; + var renderCount = component.RenderCount; - component.UnauthorizedCount.Should().Be(0); + cut.InvokeAsync(() => component.CompleteHandler()); - state.Clear(); + cut.WaitForAssertion(() => + { + component.HandlerCompleted.Should().BeTrue(); + }); - component.UnauthorizedCount.Should().Be(1); + component.RenderCount.Should().Be(renderCount); } [Fact] - public void Dispose_UnsubscribesFromAuthState() + public void StateChange_AfterDispose_DoesNotInvokeHandler() { - var state = AuthenticatedState(); + var state = UAuthState.Anonymous(); var cut = RenderWithState(state); - - var component = cut - .FindComponent() - .Instance; - - state.MarkStale(); - - component.StateChangedCount.Should().Be(1); + var component = GetComponent(cut); component.Dispose(); - state.MarkValidated(DateTimeOffset.UtcNow); + state.MarkStale(); - component.StateChangedCount.Should().Be(1); - component.LastChangeReason - .Should().Be(UAuthStateChangeReason.MarkedStale); + component.StateChangedCount.Should().Be(0); + component.Disposed.Should().BeTrue(); } + [Fact] - public void AuthStateCleared_WithAuthorizeAttribute_CallsUnauthorized() + public void CascadingStateReplacement_UnsubscribesFromPreviousState() { - var state = AuthenticatedState(); + var firstState = AuthenticatedState(); + var secondState = AuthenticatedState(); - var cut = RenderWithState(state); + var cut = Render(parameters => parameters + .Add(x => x.State, firstState)); - var component = cut - .FindComponent() - .Instance; + var originalComponent = cut.FindComponent().Instance; - component.UnauthorizedCount.Should().Be(0); + originalComponent.CurrentAuthState + .Should().BeSameAs(firstState); - state.Clear(); + cut.Render(parameters => parameters + .Add(x => x.State, secondState)); - component.UnauthorizedCount.Should().Be(1); + var component = cut.FindComponent().Instance; + + // The same component instance should receive the new cascading state. + component.Should().BeSameAs(originalComponent); + + component.CurrentAuthState + .Should().BeSameAs(secondState); + + firstState.MarkStale(); + + component.StateChangedCount.Should().Be(0); + + secondState.MarkStale(); + + cut.WaitForAssertion(() => + { + component.StateChangedCount.Should().Be(1); + component.LastChangeReason + .Should().Be(UAuthStateChangeReason.MarkedStale); + }); } [Fact] - public void InitialRender_WithAuthorizeAttribute_DoesNotEvaluateAuthorizationPrematurely() + public void MarkStale_OnAuthenticatedState_RaisesChangedEvent() { - var state = UAuthState.Anonymous(); + var state = AuthenticatedState(); + + var count = 0; + UAuthStateChangeReason? reason = null; - var cut = RenderWithState(state); + state.Changed += changeReason => + { + count++; + reason = changeReason; + }; - var component = cut - .FindComponent() - .Instance; + state.MarkStale(); - component.UnauthorizedCount.Should().Be(0); - component.ForbiddenCount.Should().Be(0); + count.Should().Be(1); + reason.Should().Be(UAuthStateChangeReason.MarkedStale); } + private IRenderedComponent> RenderWithState(UAuthState state) where TComponent : IComponent { return Render>(parameters => @@ -311,81 +302,102 @@ private IRenderedComponent> RenderWithState()); } - [UAuthAuthorize] - private sealed class AuthenticationRequiredComponent - : TestComponent - { - } - - [UAuthAuthorize(Roles = "admin")] - private sealed class AdminRequiredComponent - : TestComponent + private static TComponent GetComponent( + IRenderedComponent> cut) + where TComponent : IComponent { + return cut.FindComponent().Instance; } - [UAuthAuthorize(Permissions = "users.read")] - private sealed class ReadUsersRequiredComponent - : TestComponent + private sealed class StateHost : ComponentBase { - } + [Parameter] + public UAuthState State { get; set; } = default!; - [UAuthAuthorize(Roles = "admin,manager")] - private sealed class AdminOrManagerComponent : TestComponent - { - } - - [UAuthAuthorize(Permissions = "users.read,users.write")] - private sealed class ReadOrWriteUsersComponent : TestComponent - { + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", State); + builder.AddAttribute(2, "ChildContent", + (RenderFragment)(content => + { + content.OpenComponent(0); + content.CloseComponent(); + })); + builder.CloseComponent(); + } } - private class TestComponent : UAuthComponentBase { public int StateChangedCount { get; private set; } - public UAuthStateChangeReason? LastChangeReason { get; private set; } - - public int UnauthorizedCount { get; private set; } + public UAuthStateChangeReason? LastChangeReason + { get; private set; } - public int ForbiddenCount { get; private set; } + public int RenderCount { get; private set; } public UAuthState CurrentAuthState => AuthState; - protected override void HandleAuthStateChanged( + public bool Disposed => IsDisposed; + + protected override Task HandleAuthStateChangedAsync( UAuthStateChangeReason reason) { StateChangedCount++; LastChangeReason = reason; - } - protected override void OnUnauthorized() - { - UnauthorizedCount++; - } - - protected override void OnForbidden() - { - ForbiddenCount++; + return Task.CompletedTask; } protected override void BuildRenderTree( RenderTreeBuilder builder) { + RenderCount++; builder.AddContent(0, "uauth-test-component"); } } - private static UAuthState AuthenticatedState(string[]? roles = null, string[]? permissions = null) + private sealed class NoAutoRefreshComponent : TestComponent { - var claims = new List<(string Type, string Value)>(); + protected override bool AutoRefreshOnAuthStateChanged => false; + } + + private sealed class AsyncComponent : UAuthComponentBase + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool HandlerStarted { get; private set; } + + public bool HandlerCompleted { get; private set; } - foreach (var role in roles ?? []) - claims.Add((ClaimTypes.Role, role)); + public int RenderCount { get; private set; } - foreach (var permission in permissions ?? []) - claims.Add(("uauth:permission", permission)); + protected override async Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason) + { + HandlerStarted = true; + + await _completion.Task; + + HandlerCompleted = true; + } + public void CompleteHandler() + { + _completion.TrySetResult(true); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + RenderCount++; + builder.AddContent(0, "async-component"); + } + } + + private static UAuthState AuthenticatedState() + { var state = UAuthState.Anonymous(); state.ApplySnapshot( @@ -397,7 +409,7 @@ private static UAuthState AuthenticatedState(string[]? roles = null, string[]? p Tenant = TenantKey.Single, PrimaryUserName = "alice" }, - Claims = ClaimsSnapshot.From(claims.ToArray()) + Claims = ClaimsSnapshot.From([]) }, DateTimeOffset.UtcNow); From 795f58a3ac38be69e06358c0104d35fd1cf0eb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Tue, 20 Oct 2026 00:15:00 +0300 Subject: [PATCH 4/4] Add Integration Tests --- .../Attributes/UAuthAuthorizeAttribute.cs | 1 + ...AuthBlazorAuthorizationIntegrationTests.cs | 300 ++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthBlazorAuthorizationIntegrationTests.cs diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs index c6c144dd..2fd195c8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs @@ -2,6 +2,7 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; +// TODO: Make this to work on controllers. Maybe needed to move core. /// /// Declares UltimateAuth authorization requirements for a Blazor component or page. /// diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthBlazorAuthorizationIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthBlazorAuthorizationIntegrationTests.cs new file mode 100644 index 00000000..c752fafd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthBlazorAuthorizationIntegrationTests.cs @@ -0,0 +1,300 @@ +using AwesomeAssertions; +using Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Client.Blazor; + +public sealed class UAuthBlazorAuthorizationIntegrationTests : BunitContext +{ + public UAuthBlazorAuthorizationIntegrationTests() + { + Services.AddAuthorizationCore(); + Services.AddCascadingAuthenticationState(); + + Services.AddScoped(); + Services.AddScoped(); + } + + [Fact] + public void AnonymousUser_CannotInstantiateProtectedPage() + { + ProtectedPage.Reset(); + + var state = UAuthState.Anonymous(); + + var cut = RenderRoute(state); + + cut.WaitForAssertion(() => + { + cut.FindAll(".not-authorized").Should().ContainSingle(); + cut.FindAll(".protected-page").Should().BeEmpty(); + + ProtectedPage.CreatedCount.Should().Be(0); + }); + } + + [Fact] + public void Login_GrantsAccessToProtectedPage() + { + ProtectedPage.Reset(); + + var state = UAuthState.Anonymous(); + + var cut = RenderRoute(state); + + cut.WaitForAssertion(() => + cut.FindAll(".not-authorized").Should().ContainSingle()); + + ApplyAuthenticatedSnapshot( + state, + roles: ["Admin"], + permissions: ["users.write"]); + + cut.WaitForAssertion(() => + { + cut.FindAll(".protected-page").Should().ContainSingle(); + cut.FindAll(".not-authorized").Should().BeEmpty(); + + ProtectedPage.CreatedCount.Should().Be(1); + }); + } + + [Fact] + public void Logout_RemovesPreviouslyAuthorizedPage() + { + ProtectedPage.Reset(); + + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.write"]); + + var cut = RenderRoute(state); + + cut.WaitForAssertion(() => + cut.FindAll(".protected-page").Should().ContainSingle()); + + state.Clear(); + + cut.WaitForAssertion(() => + { + cut.FindAll(".protected-page").Should().BeEmpty(); + cut.FindAll(".not-authorized").Should().ContainSingle(); + }); + + // The page must no longer exist in the component tree. + cut.FindComponents().Should().BeEmpty(); + } + + [Fact] + public void RouteAndSection_ProduceConsistentAuthorizationDecisions() + { + var authorizedState = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.write"]); + + var deniedState = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + AssertConsistentDecision(authorizedState, expected: true); + AssertConsistentDecision(deniedState, expected: false); + } + + private void AssertConsistentDecision(UAuthState state, bool expected) + { + using var route = RenderRoute(state); + using var section = Render(parameters => + parameters.Add(x => x.State, state)); + + route.WaitForAssertion(() => + { + route.FindAll(".protected-page").Any() + .Should().Be(expected); + }); + + section.WaitForAssertion(() => + { + section.FindAll(".authorized-section").Any() + .Should().Be(expected); + }); + } + + private IRenderedComponent RenderRoute( + UAuthState state) + where TPage : IComponent + { + return Render(parameters => parameters + .Add(x => x.PageType, typeof(TPage)) + .Add(x => x.State, state)); + } + + private sealed class RouteHost : ComponentBase + { + [Parameter] + public Type PageType { get; set; } = default!; + + [Parameter] + public UAuthState State { get; set; } = default!; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", State); + builder.AddAttribute(2, "ChildContent", + (RenderFragment)(content => + { + content.OpenComponent>(0); + content.AddAttribute(1, "Name", "UAuthRuntimeReady"); + content.AddAttribute(2, "Value", true); + content.AddAttribute(3, "ChildContent", + (RenderFragment)(inner => + { + inner.OpenComponent(0); + inner.AddAttribute( + 1, + "RouteData", + new RouteData(PageType, + new Dictionary())); + + inner.AddAttribute( + 2, + "NotAuthorized", + (RenderFragment)(_ => + denied => + { + denied.OpenElement(0, "div"); + denied.AddAttribute( + 1, "class", "not-authorized"); + denied.AddContent(2, "Access denied"); + denied.CloseElement(); + })); + + inner.CloseComponent(); + })); + content.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + private sealed class SectionHost : ComponentBase + { + [Parameter] + public UAuthState State { get; set; } = default!; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", State); + builder.AddAttribute(2, "ChildContent", + (RenderFragment)(content => + { + content.OpenComponent(0); + content.AddAttribute(1, "Roles", "Admin"); + content.AddAttribute(2, "Permissions", "users.write"); + content.AddAttribute( + 3, + "RequireActive", + false); + + content.AddAttribute( + 4, + "Authorized", + (RenderFragment)(_ => authorized => + { + authorized.OpenElement(0, "div"); + authorized.AddAttribute(1, "class", "authorized-section"); + authorized.AddContent(2, "Authorized"); + authorized.CloseElement(); + })); + + content.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + [UAuthAuthorize( + Roles = "Admin", + Permissions = "users.write", + RequireActive = false)] + private sealed class ProtectedPage : ComponentBase + { + public static int CreatedCount { get; private set; } + + public ProtectedPage() + { + CreatedCount++; + } + + public static void Reset() + { + CreatedCount = 0; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "protected-page"); + builder.AddContent(2, "Protected content"); + builder.CloseElement(); + } + } + + private static UAuthState AuthenticatedState( + string[]? roles = null, + string[]? permissions = null) + { + var state = UAuthState.Anonymous(); + + ApplyAuthenticatedSnapshot(state, roles, permissions); + + return state; + } + + private static void ApplyAuthenticatedSnapshot( + UAuthState state, + string[]? roles = null, + string[]? permissions = null) + { + var claims = new List<(string Type, string Value)>(); + + foreach (var role in roles ?? []) + claims.Add((System.Security.Claims.ClaimTypes.Role, role)); + + foreach (var permission in permissions ?? []) + claims.Add(("uauth:permission", permission)); + + state.ApplySnapshot( + new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.New(), + Tenant = TenantKey.Single, + PrimaryUserName = "alice" + }, + Claims = ClaimsSnapshot.From(claims.ToArray()) + }, + DateTimeOffset.UtcNow); + } + + private sealed class TestAuthenticationStateProvider : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + + return Task.FromResult(new AuthenticationState(principal)); + } + } +}