From 6e2701ba1de61b5be5205c01554a62992915a0fe Mon Sep 17 00:00:00 2001 From: Boromir Date: Fri, 24 Apr 2026 16:38:24 -0700 Subject: [PATCH] =?UTF-8?q?fix(quality):=20resolve=20Web=20infrastructure?= =?UTF-8?q?=20warnings=20(CA1062,=20CA1031,=20CA2007)=20=E2=80=94=20closes?= =?UTF-8?q?=20#153=20-=20Add=20null-guard=20ArgumentNullException.ThrowIfN?= =?UTF-8?q?ull=20for=20external=20parameters=20(CA1062)=20-=20Replace=20ca?= =?UTF-8?q?tch(Exception)=20with=20specific=20exception=20types=20(CA1031)?= =?UTF-8?q?=20-=20Add=20ConfigureAwait(false)=20to=20async=20calls=20in=20?= =?UTF-8?q?handlers=20(CA2007)=20-=20Add=20InternalsVisibleTo=20+=20CLSCom?= =?UTF-8?q?pliant(false)=20to=20Web.csproj=20-=20Formatting=20and=20whites?= =?UTF-8?q?pace=20alignment=20throughout=20Web=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/AppHost/AppHost.cs | 8 +- .../Components/Theme/ThemeProvider.razor.cs | 14 +- src/Web/Data/BlogDbContext.cs | 4 +- src/Web/Data/BlogPostDto.cs | 14 +- .../BlogPosts/Create/CreateBlogPostHandler.cs | 44 +-- .../BlogPosts/Delete/DeleteBlogPostHandler.cs | 56 ++-- .../BlogPosts/Edit/EditBlogPostHandler.cs | 116 +++---- .../BlogPosts/List/GetBlogPostsHandler.cs | 52 +-- .../UserManagement/UserManagementHandler.cs | 312 +++++++++--------- src/Web/GlobalUsings.cs | 2 + src/Web/Program.cs | 4 +- src/Web/Security/RoleClaimsHelper.cs | 238 ++++++------- src/Web/Web.csproj | 70 ++-- 13 files changed, 472 insertions(+), 462 deletions(-) diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index 74b89caa..96609fd0 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -14,10 +14,10 @@ var redis = builder.AddRedis("redis"); builder.AddProject("web") - .WithReference(mongoDb) - .WithReference(redis) - .WaitFor(mongo) - .WaitFor(redis); + .WithReference(mongoDb) + .WithReference(redis) + .WaitFor(mongo) + .WaitFor(redis); builder.Build().Run(); diff --git a/src/Web/Components/Theme/ThemeProvider.razor.cs b/src/Web/Components/Theme/ThemeProvider.razor.cs index f392be4e..557a72a1 100644 --- a/src/Web/Components/Theme/ThemeProvider.razor.cs +++ b/src/Web/Components/Theme/ThemeProvider.razor.cs @@ -14,7 +14,7 @@ namespace MyBlog.Web.Components.Theme; public partial class ThemeProvider : ComponentBase { - [Inject] private IJSRuntime Js { get; set; } = default!; + [Inject] private IJSRuntime Js { get; set; } = null!; [Parameter] public RenderFragment? ChildContent { get; set; } @@ -27,18 +27,18 @@ protected override async Task OnAfterRenderAsync(bool firstRender) try { - CurrentColor = await Js.InvokeAsync("themeManager.getColor"); + CurrentColor = await Js.InvokeAsync("themeManager.getColor").ConfigureAwait(true); } - catch + catch (JSException) { // Keep default if localStorage is unavailable } try { - CurrentBrightness = await Js.InvokeAsync("themeManager.getBrightness"); + CurrentBrightness = await Js.InvokeAsync("themeManager.getBrightness").ConfigureAwait(true); } - catch + catch (JSException) { // Keep default if localStorage is unavailable } @@ -50,13 +50,13 @@ public async Task SetColor(string color) { CurrentColor = color; StateHasChanged(); - await Js.InvokeVoidAsync("themeManager.setColor", color); + await Js.InvokeVoidAsync("themeManager.setColor", color).ConfigureAwait(true); } public async Task SetBrightness(string brightness) { CurrentBrightness = brightness; StateHasChanged(); - await Js.InvokeVoidAsync("themeManager.setBrightness", brightness); + await Js.InvokeVoidAsync("themeManager.setBrightness", brightness).ConfigureAwait(true); } } diff --git a/src/Web/Data/BlogDbContext.cs b/src/Web/Data/BlogDbContext.cs index a49ccba9..e5d6c2ef 100644 --- a/src/Web/Data/BlogDbContext.cs +++ b/src/Web/Data/BlogDbContext.cs @@ -11,12 +11,14 @@ namespace MyBlog.Web.Data; -public sealed class BlogDbContext(DbContextOptions options) : DbContext(options) +internal sealed class BlogDbContext(DbContextOptions options) : DbContext(options) { public DbSet BlogPosts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + ArgumentNullException.ThrowIfNull(modelBuilder); + var entity = modelBuilder.Entity(); entity.ToCollection("blogposts"); entity.HasKey(p => p.Id); diff --git a/src/Web/Data/BlogPostDto.cs b/src/Web/Data/BlogPostDto.cs index 77cd7b11..37943351 100644 --- a/src/Web/Data/BlogPostDto.cs +++ b/src/Web/Data/BlogPostDto.cs @@ -10,10 +10,10 @@ namespace MyBlog.Web.Data; internal sealed record BlogPostDto( - Guid Id, - string Title, - string Content, - string Author, - DateTime CreatedAt, - DateTime? UpdatedAt, - bool IsPublished); + Guid Id, + string Title, + string Content, + string Author, + DateTime CreatedAt, + DateTime? UpdatedAt, + bool IsPublished); diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs index b9d0a1f2..bc0cafbe 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs @@ -16,28 +16,28 @@ internal sealed class CreateBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler> { -public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = BlogPost.Create(request.Title, request.Content, request.Author); -await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -return Result.Ok(post.Id); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = BlogPost.Create(request.Title, request.Content, request.Author); + await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(post.Id); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs index ec45bd13..2dfef6bd 100644 --- a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs @@ -16,34 +16,34 @@ internal sealed class DeleteBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler { -public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs index 88278245..e7cd4c25 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs @@ -18,67 +18,67 @@ internal sealed class EditBlogPostHandler( : IRequestHandler, IRequestHandler> { -public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -if (post is null) -return Result.Fail($"BlogPost {request.Id} not found."); -post.Update(request.Title, request.Content); -await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (post is null) + return Result.Fail($"BlogPost {request.Id} not found."); + post.Update(request.Title, request.Content); + await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) -{ -try -{ -var dto = await cache.GetOrFetchByIdAsync( -request.Id, -async () => -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return post?.ToDto(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok(dto); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) + { + try + { + var dto = await cache.GetOrFetchByIdAsync( + request.Id, + async () => + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return post?.ToDto(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok(dto); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs index 18276f74..601be879 100644 --- a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs +++ b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs @@ -16,32 +16,32 @@ internal sealed class GetBlogPostsHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler>> { -public async Task>> Handle( -GetBlogPostsQuery request, CancellationToken cancellationToken) -{ -try -{ -var result = await cache.GetOrFetchAllAsync( -async () => -{ -var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); -return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetBlogPostsQuery request, CancellationToken cancellationToken) + { + try + { + var result = await cache.GetOrFetchAllAsync( + async () => + { + var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); + return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 9797286c..044eb8ef 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -22,174 +22,174 @@ internal sealed class UserManagementHandler( IRequestHandler, IRequestHandler>> { -public async Task>> Handle( -GetUsersWithRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var result = new List(); -await foreach (var user in usersPager) -{ -var rolesPager = await client.Users.Roles.ListAsync( -user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager) -{ -roles.Add(role.Name ?? string.Empty); -} -result.Add(new UserWithRolesDto( -user.UserId ?? string.Empty, -user.Email ?? string.Empty, -user.Name ?? user.Email ?? string.Empty, -roles)); -} -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetUsersWithRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var result = new List(); + await foreach (var user in usersPager) + { + var rolesPager = await client.Users.Roles.ListAsync( + user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager) + { + roles.Add(role.Name ?? string.Empty); + } + result.Add(new UserWithRolesDto( + user.UserId ?? string.Empty, + user.Email ?? string.Empty, + user.Name ?? user.Email ?? string.Empty, + roles)); + } + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.AssignAsync( -request.UserId, -new AssignUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.AssignAsync( + request.UserId, + new AssignUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.DeleteAsync( -request.UserId, -new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.DeleteAsync( + request.UserId, + new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager) -{ -roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); -} -return Result.Ok>(roles); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager) + { + roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); + } + return Result.Ok>(roles); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -private async Task GetManagementClientAsync(CancellationToken cancellationToken) -{ -var domain = configuration["Auth0:ManagementApiDomain"] -?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); -var clientId = configuration["Auth0:ManagementApiClientId"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); -var clientSecret = configuration["Auth0:ManagementApiClientSecret"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); + private async Task GetManagementClientAsync(CancellationToken cancellationToken) + { + var domain = configuration["Auth0:ManagementApiDomain"] + ?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); + var clientId = configuration["Auth0:ManagementApiClientId"] + ?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); + var clientSecret = configuration["Auth0:ManagementApiClientSecret"] + ?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); -var httpClient = httpClientFactory.CreateClient(); -var tokenResponse = await httpClient.PostAsJsonAsync( -$"https://{domain}/oauth/token", -new -{ -client_id = clientId, -client_secret = clientSecret, -audience = $"https://{domain}/api/v2/", -grant_type = "client_credentials" -}, cancellationToken).ConfigureAwait(false); -tokenResponse.EnsureSuccessStatusCode(); -var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); -return new ManagementApiClient( -token: tokenData!.AccessToken, -clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); -} + var httpClient = httpClientFactory.CreateClient(); + var tokenResponse = await httpClient.PostAsJsonAsync( + $"https://{domain}/oauth/token", + new + { + client_id = clientId, + client_secret = clientSecret, + audience = $"https://{domain}/api/v2/", + grant_type = "client_credentials" + }, cancellationToken).ConfigureAwait(false); + tokenResponse.EnsureSuccessStatusCode(); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + return new ManagementApiClient( + token: tokenData!.AccessToken, + clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); + } -private sealed class TokenResponse -{ -public string AccessToken { get; init; } = string.Empty; -} + private sealed class TokenResponse + { + public string AccessToken { get; init; } = string.Empty; + } } diff --git a/src/Web/GlobalUsings.cs b/src/Web/GlobalUsings.cs index 551ae07b..8e4b29eb 100644 --- a/src/Web/GlobalUsings.cs +++ b/src/Web/GlobalUsings.cs @@ -8,9 +8,11 @@ //======================================================= global using MediatR; + global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 14bf56e9..a18e7059 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -115,7 +115,7 @@ // MediatR — scans Web assembly for all handlers builder.Services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); }); // FluentValidation — scans Web assembly for all validators @@ -207,4 +207,4 @@ static async Task MapTestLoginEndpoint(HttpContext ctx, string? role) // Exclude the compiler-generated Program class (top-level bootstrap statements) from coverage. [ExcludeFromCodeCoverage(Justification = "Application bootstrap entry-point — not business logic")] -public partial class Program { } +internal partial class Program { } diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs index 02895781..22241270 100644 --- a/src/Web/Security/RoleClaimsHelper.cs +++ b/src/Web/Security/RoleClaimsHelper.cs @@ -14,123 +14,123 @@ namespace MyBlog.Web.Security; internal static class RoleClaimsHelper { - public static readonly string[] DefaultRoleClaimTypes = - [ - "https://myblog/roles", - "roles", - "role" - ]; - - public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) - { - var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); - - return configured is { Length: > 0 } - ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - : DefaultRoleClaimTypes; - } - - public static bool IsRoleClaimType(string? claimType) - { - if (string.IsNullOrWhiteSpace(claimType)) - { - return false; - } - - if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) - || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) - || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var lastSlash = claimType.LastIndexOf('/'); - var lastColon = claimType.LastIndexOf(':'); - var separatorIndex = Math.Max(lastSlash, lastColon); - var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; - - return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) - || tail.Equals("role", StringComparison.OrdinalIgnoreCase); - } - - private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) - { - return (roleClaimTypes ?? DefaultRoleClaimTypes) - .Append(ClaimTypes.Role) - .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public static IReadOnlyList ExpandRoleValues(string? claimValue) - { - if (string.IsNullOrWhiteSpace(claimValue)) - { - return []; - } - - var trimmed = claimValue.Trim(); - - if (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - try - { - using var document = JsonDocument.Parse(trimmed); - - if (document.RootElement.ValueKind == JsonValueKind.Array) - { - return document.RootElement - .EnumerateArray() - .Select(element => element.GetString()) - .Where(role => !string.IsNullOrWhiteSpace(role)) - .Cast() - .ToArray(); - } - } - catch (JsonException) - { - return []; - } - } - - if (trimmed.Contains(',', StringComparison.Ordinal)) - { - return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - } - - return [trimmed]; - } - - public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) - { - ArgumentNullException.ThrowIfNull(identity); - - foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) - { - foreach (var claim in identity.FindAll(roleClaimType).ToList()) - { - foreach (var role in ExpandRoleValues(claim.Value)) - { - if (!identity.HasClaim(ClaimTypes.Role, role)) - { - identity.AddClaim(new Claim(ClaimTypes.Role, role)); - } - } - } - } - } - - public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) - { - var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); - - return user.Claims - .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) - .SelectMany(claim => ExpandRoleValues(claim.Value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(role => role) - .ToList(); - } + public static readonly string[] DefaultRoleClaimTypes = + [ + "https://myblog/roles", + "roles", + "role" + ]; + + public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) + { + var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); + + return configured is { Length: > 0 } + ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : DefaultRoleClaimTypes; + } + + public static bool IsRoleClaimType(string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return false; + } + + if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) + || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) + || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var lastSlash = claimType.LastIndexOf('/'); + var lastColon = claimType.LastIndexOf(':'); + var separatorIndex = Math.Max(lastSlash, lastColon); + var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; + + return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) + || tail.Equals("role", StringComparison.OrdinalIgnoreCase); + } + + private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) + { + return (roleClaimTypes ?? DefaultRoleClaimTypes) + .Append(ClaimTypes.Role) + .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyList ExpandRoleValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + var trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using var document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Select(element => element.GetString()) + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + return []; + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) + { + ArgumentNullException.ThrowIfNull(identity); + + foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) + { + foreach (var claim in identity.FindAll(roleClaimType).ToList()) + { + foreach (var role in ExpandRoleValues(claim.Value)) + { + if (!identity.HasClaim(ClaimTypes.Role, role)) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } + } + } + } + } + + public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) + { + var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); + + return user.Claims + .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) + .SelectMany(claim => ExpandRoleValues(claim.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(role => role) + .ToList(); + } } diff --git a/src/Web/Web.csproj b/src/Web/Web.csproj index 98488f4d..4649ed1a 100644 --- a/src/Web/Web.csproj +++ b/src/Web/Web.csproj @@ -1,40 +1,46 @@ - - - - + + net10.0 + enable + enable + true + MyBlog.Web + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + false + false + false + - - - - - - - - - - - + + + + - - net10.0 - enable - enable - true - MyBlog.Web - a1b2c3d4-e5f6-7890-abcd-ef1234567890 - - false - false - false - - - - - - + + + + + + + + + + + + + + + + + + + + + +