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 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