Files
Nexus.Reader/.agent/skills/blazor-expert/resources/authentication-authorization.md
Antigravity 5a2223a4c8 feat: Ingestion Pipeline Stabilization and WASM Service Proxies (#42)
This PR stabilizes the Nexus Ingestion Engine by implementing functional service proxies for the Blazor WASM client and refining the backend infrastructure for real-time progress tracking and database compatibility.

### Key Changes
- **Infrastructure Stabilization**:
  - Implemented production-grade `EbookRepository` with PostgreSQL `EF.Functions.ILike` support.
  - Enforced `IsReadyForReading = false` state for newly added ebooks (resolves #35).
  - Updated `SignalRSyncBroadcaster` to support targeted user messaging and ingestion-specific progress updates (resolves #37).
- **WASM Client Functional Proxies**:
  - Replaced "Throwing" dummy services with `WasmEbookRepository`, `WasmSyncBroadcaster`, `WasmBookStorageService`, and `WasmEmbeddingGenerator`.
  - These services proxy requests to the backend via a new set of Minimal API endpoints in `NexusReader.Web`.
- **Domain Refinement**:
  - Added `IsReadyForReading` flag to the `Ebook` entity to manage background AI processing states.

### Related Issues
- Fixes #35
- Fixes #36
- Fixes #37

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #42
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-13 18:24:24 +00:00

12 KiB

Blazor Authentication & Authorization

Authentication Setup

Blazor Server Setup

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add authentication
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.LogoutPath = "/logout";
        options.AccessDeniedPath = "/unauthorized";
    });

builder.Services.AddAuthorizationCore();

// Add Blazor Server
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();

var app = builder.Build();

// Add authentication middleware BEFORE MapRazorPages
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
app.MapBlazorHub();

Blazor WebAssembly Setup

// Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.Add<App>("#app");

// Add authentication
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<HttpClient>(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();

// CustomAuthStateProvider
public class CustomAuthStateProvider : AuthenticationStateProvider
{
    private readonly HttpClient httpClient;

    public CustomAuthStateProvider(HttpClient httpClient)
    {
        this.httpClient = httpClient;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        try
        {
            var user = await httpClient.GetJsonAsync<UserInfo>("/api/user");
            
            var claims = new[]
            {
                new Claim(ClaimTypes.NameIdentifier, user.Id),
                new Claim(ClaimTypes.Name, user.Name),
                new Claim(ClaimTypes.Email, user.Email)
            };

            var identity = new ClaimsIdentity(claims, "Custom");
            return new AuthenticationState(new ClaimsPrincipal(identity));
        }
        catch
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }
    }
}

AuthorizeView Component

AuthorizeView displays content conditionally based on authorization status.

Basic Authorization Check

<AuthorizeView>
    <Authorized>
        <p>Hello, @context.User.Identity?.Name!</p>
    </Authorized>
    <NotAuthorized>
        <p>Please log in.</p>
    </NotAuthorized>
</AuthorizeView>

Authorize by Role

<AuthorizeView Roles="Admin">
    <p>This content is only for Admins</p>
</AuthorizeView>

<AuthorizeView Roles="User, Moderator">
    <p>User or Moderator content</p>
</AuthorizeView>

Authorize by Policy

<AuthorizeView Policy="ContentEditor">
    <p>Only content editors can see this</p>
</AuthorizeView>

Multiple AuthorizeView States

<AuthorizeView>
    <Authorized>
        @if (context.User.IsInRole("Admin"))
        {
            <p>Admin dashboard</p>
        }
        else if (context.User.IsInRole("Editor"))
        {
            <p>Editor dashboard</p>
        }
        else
        {
            <p>User dashboard</p>
        }
    </Authorized>
    <Authorizing>
        <p>Checking authorization...</p>
    </Authorizing>
    <NotAuthorized>
        <p>Not authorized</p>
    </NotAuthorized>
</AuthorizeView>

Authorize Multiple Resources

<AuthorizeView Context="Auth">
    <Authorized>
        <div>
            <h2>@Auth.User.Identity?.Name</h2>
            
            @if (Auth.User.IsInRole("Admin"))
            {
                <a href="/admin">Admin Panel</a>
            }
            
            @if (Auth.User.HasClaim("department", "engineering"))
            {
                <a href="/engineering">Engineering</a>
            }
        </div>
    </Authorized>
</AuthorizeView>

Authorize Attribute

Apply [Authorize] to pages to require authentication.

Basic Page Authorization

@page "/admin"
@attribute [Authorize]

<h2>Admin Page</h2>
<p>Only authenticated users can see this.</p>

Role-Based Authorization

@page "/admin"
@attribute [Authorize(Roles = "Admin")]

<h2>Admin Panel</h2>
<p>Only admins can access this page.</p>

Policy-Based Authorization

@page "/dashboard"
@attribute [Authorize(Policy = "RequireAdminRole")]

<h2>Dashboard</h2>

Multiple Requirements

@page "/admin"
@attribute [Authorize(Roles = "Admin, Manager")]
@attribute [Authorize(Policy = "ActiveSubscription")]

<h2>Admin Dashboard</h2>

Authorization Policies

Define fine-grained authorization policies.

Setup Policies

// Program.cs
builder.Services.AddAuthorizationCore(options =>
{
    options.AddPolicy("RequireAdminRole", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("ActiveSubscription", policy =>
        policy.Requirements.Add(new ActiveSubscriptionRequirement()));

    options.AddPolicy("ContentEditor", policy =>
        policy.RequireClaim("department", "engineering", "content"));

    options.AddPolicy("AdultUser", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Add custom policy handler
builder.Services.AddSingleton<IAuthorizationHandler, ActiveSubscriptionHandler>();

Custom Policy Handlers

public class ActiveSubscriptionRequirement : IAuthorizationRequirement { }

public class ActiveSubscriptionHandler : AuthorizationHandler<ActiveSubscriptionRequirement>
{
    private readonly IUserService userService;

    public ActiveSubscriptionHandler(IUserService userService)
    {
        this.userService = userService;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ActiveSubscriptionRequirement requirement)
    {
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        
        if (string.IsNullOrEmpty(userId))
        {
            context.Fail();
            return;
        }

        var user = await userService.GetUserAsync(userId);
        
        if (user?.SubscriptionActive == true)
        {
            context.Succeed(requirement);
        }
        else
        {
            context.Fail();
        }
    }
}

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; set; }

    public MinimumAgeRequirement(int minimumAge)
    {
        MinimumAge = minimumAge;
    }
}

Accessing Authentication State

In Components

@page "/user-profile"

@if (authState == null)
{
    <p>Loading...</p>
}
else if (authState.User.Identity?.IsAuthenticated == true)
{
    <h2>Welcome, @authState.User.Identity?.Name</h2>
}
else
{
    <p>Not authenticated</p>
}

@code {
    [CascadingParameter]
    private Task<AuthenticationState>? AuthStateTask { get; set; }

    private AuthenticationState? authState;

    protected override async Task OnInitializedAsync()
    {
        authState = await AuthStateTask!;
    }
}

Check Claims and Roles

@code {
    private async Task CheckUserAsync()
    {
        var authState = await AuthStateTask!;
        var user = authState.User;

        if (user.Identity?.IsAuthenticated == true)
        {
            var name = user.Identity.Name;
            var email = user.FindFirst(ClaimTypes.Email)?.Value;
            var isAdmin = user.IsInRole("Admin");
            var department = user.FindFirst("department")?.Value;
        }
    }
}

Login/Logout Implementation

Login Page

@page "/login"
@layout BlankLayout

<div class="login-form">
    <h2>Login</h2>

    @if (!string.IsNullOrEmpty(errorMessage))
    {
        <div class="alert alert-danger">@errorMessage</div>
    }

    <EditForm Model="@model" OnValidSubmit="@HandleLoginAsync">
        <DataAnnotationsValidator />

        <div class="form-group">
            <label>Email:</label>
            <InputText @bind-Value="model.Email" class="form-control" />
        </div>

        <div class="form-group">
            <label>Password:</label>
            <InputText @bind-Value="model.Password" type="password" class="form-control" />
        </div>

        <button type="submit" class="btn btn-primary">Login</button>
    </EditForm>
</div>

@code {
    [SupplyParameterFromQuery]
    public string? ReturnUrl { get; set; }

    @inject AuthenticationStateProvider AuthStateProvider
    @inject NavigationManager Navigation

    private LoginModel model = new();
    private string? errorMessage;

    private async Task HandleLoginAsync()
    {
        try
        {
            var result = await AuthService.LoginAsync(model.Email, model.Password);
            
            // Update authentication state
            if (AuthStateProvider is CustomAuthStateProvider customAuth)
            {
                await customAuth.SetUserAsync(result.User);
            }

            // Redirect to return URL or home
            var url = !string.IsNullOrEmpty(ReturnUrl) ? ReturnUrl : "/";
            Navigation.NavigateTo(url, forceLoad: true);
        }
        catch (Exception ex)
        {
            errorMessage = ex.Message;
        }
    }
}

public class LoginModel
{
    [Required]
    [EmailAddress]
    public string Email { get; set; } = "";

    [Required]
    public string Password { get; set; } = "";
}

Logout Endpoint

// Pages/Logout.cshtml (in Blazor Server)
@page "/logout"
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject NavigationManager Navigation

@code {
    protected override async Task OnInitializedAsync()
    {
        await SignInManager.SignOutAsync();
        Navigation.NavigateTo("/");
    }
}

Claims-Based Authorization

Working with claims for fine-grained authorization.

Add Claims to User

var claims = new List<Claim>
{
    new Claim(ClaimTypes.NameIdentifier, user.Id),
    new Claim(ClaimTypes.Name, user.Name),
    new Claim(ClaimTypes.Email, user.Email),
    new Claim("department", "engineering"),
    new Claim("level", "senior")
};

var identity = new ClaimsIdentity(claims, "Custom");
var principal = new ClaimsPrincipal(identity);

Check Claims in Component

@code {
    private async Task CheckDepartmentAsync()
    {
        var authState = await AuthStateTask!;
        var user = authState.User;

        var department = user.FindFirst("department")?.Value;
        var level = user.FindFirst("level")?.Value;

        if (department == "engineering")
        {
            // Show engineering-specific UI
        }
    }
}

Best Practices

Use Cascading AuthenticationState

// App.razor - already cascades AuthenticationState by default
<CascadingAuthenticationState>
    <Router ... />
</CascadingAuthenticationState>

Always Check firstRender in OnAfterRender

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        // Initialize only once
        authState = await AuthStateTask!;
        StateHasChanged();
    }
}

Use forceLoad for Logout

private async Task LogoutAsync()
{
    await AuthService.LogoutAsync();
    // forceLoad clears client-side state
    Navigation.NavigateTo("/", forceLoad: true);
}

Validate on Server

  • Never trust client-side authorization
  • Always validate authorization on backend API
  • Check claims/roles on server methods

Use ReturnUrl After Login

// Redirect back to originally-requested page
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");

Related Resources: See routing-navigation.md for route-based authorization. See components-lifecycle.md for parameter security.