diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs index ae253156..2fd195c8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs @@ -1,5 +1,8 @@ -namespace CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +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. /// @@ -34,4 +37,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..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,147 +3,109 @@ 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. - /// [CascadingParameter] protected UAuthState AuthState { get; set; } = default!; - /// - /// 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. + /// Automatically re-render when the authentication state changes. /// 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. + /// Indicates whether the component has been disposed. /// - /// + protected bool IsDisposed => _disposed; + 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(); - } + if (ReferenceEquals(_subscribedState, AuthState)) + return; - /// - /// 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. - } + Unsubscribe(); + + _subscribedState = AuthState; + _subscribedState.Changed += OnAuthStateChanged; } private void OnAuthStateChanged(UAuthStateChangeReason reason) { - HandleAuthStateChanged(reason); - - EvaluateAuthorization(); + if (_disposed) + return; - if (AutoRefreshOnAuthStateChanged) - _ = InvokeAsync(StateHasChanged); + _ = DispatchAuthStateChangedAsync(reason); } - /// - /// Allows derived components to react to auth state changes. - /// - protected virtual void HandleAuthStateChanged(UAuthStateChangeReason reason) + private async Task DispatchAuthStateChangedAsync(UAuthStateChangeReason reason) { - } - - private void EvaluateAuthorization() - { - var attr = GetType() - .GetCustomAttributes(typeof(UAuthAuthorizeAttribute), true) - .FirstOrDefault() as UAuthAuthorizeAttribute; + try + { + await InvokeAsync(async () => + { + if (_disposed) + return; - if (attr is null) - return; + await HandleAuthStateChangedAsync(reason); - if (_rendered && !AuthState.IsAuthenticated) - { - OnUnauthorized(); - return; + if (!_disposed && AutoRefreshOnAuthStateChanged) + StateHasChanged(); + }); } - - if (_rendered && !string.IsNullOrEmpty(attr.Roles)) + catch (ObjectDisposedException) when (_disposed) { - var roles = attr.Roles.Split(','); - - if (!roles.Any(r => AuthState.IsInRole(r.Trim()))) - { - OnForbidden(); - return; - } + // Ignore disposal-related exceptions. } - - if (_rendered && !string.IsNullOrEmpty(attr.Permissions)) + catch (Exception ex) { - var permissions = attr.Permissions.Split(','); - - if (!permissions.Any(p => AuthState.HasPermission(p.Trim()))) - { - OnForbidden(); - return; - } + if (!_disposed) + await DispatchExceptionAsync(ex); } } /// - /// Called when the component requires authentication but the current user is not authenticated. + /// Called when the authentication state changes. + /// Override to perform asynchronous state-dependent work. /// - protected virtual void OnUnauthorized() + protected virtual Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason) { - Navigation.NavigateTo("/"); + return Task.CompletedTask; } - /// - /// 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/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..70d47e4e --- /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 && !_evaluating && _uauthAuthorized)) +{ + + + + @if (NotAuthorized is not null) + { + @NotAuthorized(authenticationState) + } + else + { +

Not authorized.

+ } +
+ + + @if (Authorizing is not null) + { + @Authorizing + } + + +
+} +else if (!_runtimeReady || _evaluating) +{ + @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..62529c21 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthAuthorizeRouteView.razor.cs @@ -0,0 +1,192 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +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 _evaluating; + private long _evaluationVersion; + private bool _disposed; + + [Inject] + private IUAuthAuthorizationEvaluator AuthorizationEvaluator { get; set; } = default!; + + /// + /// 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 async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + ArgumentNullException.ThrowIfNull(RouteData); + + _requirement = RouteData.PageType + .GetCustomAttribute(inherit: true); + + _hasUAuthRequirement = _requirement is not null; + _runtimeReady = RuntimeReady; + + if (!_hasUAuthRequirement) + { + ++_evaluationVersion; + + _uauthAuthorized = true; + _evaluating = false; + + Unsubscribe(); + return; + } + + Subscribe(AuthState); + + await EvaluateAuthorizationAsync(); + } + + private async Task EvaluateAuthorizationAsync() + { + var version = ++_evaluationVersion; + + _uauthAuthorized = false; + _evaluating = false; + + if (_disposed || !_runtimeReady || _requirement is null) + return; + + var requirement = CreateRequirement(_requirement); + + var state = AuthState; + + _evaluating = true; + + try + { + var result = await AuthorizationEvaluator.EvaluateAsync(state, requirement); + + if (_disposed || version != _evaluationVersion) + return; + + _uauthAuthorized = result.IsAuthorized; + } + finally + { + if (!_disposed && version == _evaluationVersion) + { + _evaluating = false; + await InvokeAsync(StateHasChanged); + } + } + } + + private UAuthAuthorizationRequirement CreateRequirement(UAuthAuthorizeAttribute attribute) + { + return new UAuthAuthorizationRequirement + { + Roles = attribute.Roles, + Permissions = attribute.Permissions, + MatchMode = attribute.MatchMode, + RequireActive = attribute.RequireActive + }; + } + + 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(async () => + { + if (_disposed) + return; + + 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..f25601e1 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,15 @@ 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 UAuthState? _evaluatedState; + 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 +105,16 @@ protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); - var newKey = BuildAuthKey(); - - if (_authKey == newKey) - return; - - _authKey = newKey; - _authorizing = true; + var requirement = CreateRequirement(); - if (_rolesRaw != Roles) + if (_currentRequirement == requirement && ReferenceEquals(_evaluatedState, AuthState)) { - _rolesRaw = Roles; - _rolesParsed = ParseCsv(Roles); - } - - if (_permissionsRaw != Permissions) - { - _permissionsRaw = Permissions; - _permissionsParsed = ParseCsv(Permissions); + return; } - EvaluateSessionState(); - _authorized = await EvaluateAuthorizationAsync(); - _authorizing = false; + _currentRequirement = requirement; + _evaluatedState = AuthState; + await ReevaluateAuthorizationAsync(requirement); } /// @@ -134,141 +122,64 @@ 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) - { - 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)) - { - policyResult = await EvaluatePolicyAsync(); - } - - return MatchMode switch - { - 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) + protected override async Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason) { - 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; + if (IsDisposed) + return; - return true; + var requirement = _currentRequirement ?? CreateRequirement(); + await ReevaluateAuthorizationAsync(requirement); } - 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 (IsDisposed) + return; - if (permissions.Count > 0 && !permissions.Any(x => x)) - return false; + var version = ++_evaluationVersion; - if (policy.HasValue && !policy.Value) - return false; + _authorizing = true; + _result = UAuthAuthorizationResult.Denied; - return true; - } + // Display Authorizing immediately and remove previously + // authorized content before awaiting policy evaluation. + await InvokeAsync(StateHasChanged); - private void EvaluateSessionState() - { - if (!RequireActive) + try { - _inactive = false; - return; - } + var result = await AuthorizationEvaluator.EvaluateAsync( + AuthState, + requirement); - if (AuthState.IsAuthenticated != true) - { - _inactive = false; - return; - } + if (IsDisposed || version != _evaluationVersion) + return; - if (AuthState.Identity?.SessionState is null) + _result = result; + } + catch (Exception) when (!IsDisposed && version == _evaluationVersion) { - _inactive = false; + _result = UAuthAuthorizationResult.Denied; + throw; } - else + finally { - _inactive = AuthState.Identity?.SessionState != SessionState.Active; + if (!IsDisposed && 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 277146e7..9876619b 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..71f92154 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizationEvaluatorTests.cs @@ -0,0 +1,217 @@ +using FluentAssertions; +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 new file mode 100644 index 00000000..0f6a5578 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAuthorizeRouteViewTests.cs @@ -0,0 +1,595 @@ +using FluentAssertions; +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 Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; + +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() + { + Services.AddScoped(); + + _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++; + } +} 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..6244b9e2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthBlazorAuthorizationIntegrationTests.cs @@ -0,0 +1,300 @@ +using FluentAssertions; +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)); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs index 29e4837c..7424a213 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs @@ -7,7 +7,6 @@ using FluentAssertions; 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); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs index 4ff25daf..654b4d61 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs @@ -4,6 +4,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; @@ -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