Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
/// <summary>
/// Declares UltimateAuth authorization requirements for a Blazor component or page.
/// </summary>
Expand Down Expand Up @@ -34,4 +37,14 @@ public sealed class UAuthAuthorizeAttribute : Attribute
/// permission requirement is declared by this property.
/// </remarks>
public string? Permissions { get; set; }

/// <summary>
/// Determines how roles, permissions, and other authorization requirements are combined.
/// </summary>
public AuthorizationMatchMode MatchMode { get; set; } = AuthorizationMatchMode.Category;

/// <summary>
/// Determines whether the current session must be active.
/// </summary>
public bool RequireActive { get; set; } = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;

/// <summary>
/// Evaluates UltimateAuth authorization requirements.
/// Implementations should not manage component lifecycle or rendering.
/// </summary>
public interface IUAuthAuthorizationEvaluator
{
Task<UAuthAuthorizationResult> EvaluateAsync(UAuthState state, UAuthAuthorizationRequirement requirement);
}
Original file line number Diff line number Diff line change
@@ -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<UAuthAuthorizationResult> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using CodeBeam.UltimateAuth.Core.Contracts;

namespace CodeBeam.UltimateAuth.Client.Blazor;

/// <summary>
/// Defines the authorization requirements evaluated by UltimateAuth
/// Blazor authorization components.
/// </summary>
public sealed record UAuthAuthorizationRequirement
{
/// <summary>
/// Comma-separated roles.
/// </summary>
public string? Roles { get; init; }

/// <summary>
/// Comma-separated permissions.
/// </summary>
public string? Permissions { get; init; }

/// <summary>
/// Optional ASP.NET Core authorization policy.
/// </summary>
public string? Policy { get; init; }

/// <summary>
/// Determines how authorization conditions are combined.
/// </summary>
public AuthorizationMatchMode MatchMode { get; init; } = AuthorizationMatchMode.Category;

/// <summary>
/// Requires an active session when enabled.
/// </summary>
public bool RequireActive { get; init; } = true;
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,147 +3,109 @@
namespace CodeBeam.UltimateAuth.Client.Blazor;

/// <summary>
/// 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.
/// </summary>
public abstract class UAuthComponentBase : ComponentBase, IDisposable
{
private UAuthState? _previousState;
private bool _rendered;
private UAuthState? _subscribedState;
private bool _disposed;

/// <summary>
/// Gets the current UltimateAuth authentication state supplied by <c>UAuthApp</c>.
/// </summary>
[CascadingParameter]
protected UAuthState AuthState { get; set; } = default!;

/// <summary>
/// Gets the Blazor navigation service.
/// </summary>
[Inject] protected NavigationManager Navigation { get; set; } = default!;
[Inject]
protected NavigationManager Navigation { get; set; } = default!;

/// <summary>
/// Automatically re-render when UAuthState changes. Can be overridden to disable.
/// Automatically re-render when the authentication state changes.
/// </summary>
protected virtual bool AutoRefreshOnAuthStateChanged => true;

/// <summary>
/// Called when the component's parameters have been set. This method ensures that the component is properly registered
/// with the current <see cref="UAuthState"/> and evaluates authorization requirements.
/// Indicates whether the component has been disposed.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
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 <UAuthApp>.");

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 <UAuthApp>.");
}

EvaluateAuthorization();
}
if (ReferenceEquals(_subscribedState, AuthState))
return;

/// <summary>
/// Called after the component has been rendered. This method sets the _rendered flag to true on the first render.
/// </summary>
/// <param name="firstRender"></param>
/// <returns></returns>
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);
}

/// <summary>
/// Allows derived components to react to auth state changes.
/// </summary>
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);
}
}

/// <summary>
/// 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.
/// </summary>
protected virtual void OnUnauthorized()
protected virtual Task HandleAuthStateChangedAsync(UAuthStateChangeReason reason)
{
Navigation.NavigateTo("/");
return Task.CompletedTask;
}

/// <summary>
/// Called when the current user is authenticated but does not satisfy the authorization requirements of the component.
/// </summary>
protected virtual void OnForbidden()
private void Unsubscribe()
{
Navigation.NavigateTo("/forbidden");
if (_subscribedState is null)
return;

_subscribedState.Changed -= OnAuthStateChanged;
_subscribedState = null;
}

/// <summary>
/// Disposes of the component and unsubscribes from the <see cref="UAuthState.Changed"/> event to prevent memory leaks.
/// </summary>
public virtual void Dispose()
{
if (_previousState is not null)
_previousState.Changed -= OnAuthStateChanged;
if (_disposed)
return;

_disposed = true;
Unsubscribe();
}
}
}
Loading
Loading