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
5 changes: 4 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<!-- Aspire Packages -->
Expand All @@ -21,6 +22,8 @@
<!-- Database -->
<PackageVersion Include="MongoDB.Driver" Version="3.6.0" />
<PackageVersion Include="MongoDB.EntityFrameworkCore" Version="10.0.1" />
<!-- Transitive pin: MongoDB.Driver 3.6.x currently resolves vulnerable SharpCompress 0.30.1 -->
<PackageVersion Include="SharpCompress" Version="0.48.0" />
<PackageVersion Include="Snappier" Version="1.3.1" />
<!-- Microsoft Packages -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.7" />
Expand Down Expand Up @@ -49,4 +52,4 @@
<PackageVersion Include="xunit.v3.assert" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
</ItemGroup>
</Project>
</Project>
56 changes: 56 additions & 0 deletions docs/build-log.txt
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,62 @@ The solution builds successfully and all tests pass. The only issue is a minor
code coverage gap of 0.54%. Adding tests for approximately 5 more lines will
meet the 89% threshold.

ADDENDUM: PR #279 SHARPCOMPRESS / NU1902 CI INVESTIGATION
---------------------------------------------------------
Generated: 2026-05-10
Scope: Allowed-file remediation only. Preserved existing dirty app files in
src/Web/Features/UserManagement/UserManagementHandler.cs and
src/Web/Program.cs.

ROOT CAUSE
----------
- CI restore was failing with NU1902 because MongoDB.Driver 3.6.0 resolved the
transitive package SharpCompress 0.30.1.
- NuGet restore audit is enabled and warnings are treated as errors in CI, so
advisory GHSA-6c8g-7p36-r338 became a build-blocking restore error.
- Scratch-package verification during this session showed MongoDB.Driver 3.7.1
and 3.8.0 still resolve SharpCompress 0.30.1, so upgrading only the driver was
not a minimal safe fix.

FIX APPLIED
-----------
- Enabled CentralPackageTransitivePinningEnabled in Directory.Packages.props.
- Added a central transitive pin for SharpCompress 0.48.0.

VERIFICATION
------------
1. Command: dotnet restore MyBlog.slnx
Status: ✅ SUCCESS
Result: Restore completed without NU1902.

2. Command: dotnet build MyBlog.slnx --configuration Release --no-restore
Status: ✅ SUCCESS
Result: Build completed with 326 existing analyzer/code-quality warnings and
0 errors. No SharpCompress / NU1902 failure remained.

3. Command: dotnet package list --project src/Web/Web.csproj --include-transitive --vulnerable --format json --no-restore
Status: ✅ SUCCESS
Result: Vulnerable package count = 0

4. Command: dotnet package list --project src/AppHost/AppHost.csproj --include-transitive --vulnerable --format json --no-restore
Status: ✅ SUCCESS
Result: Vulnerable package count = 0

5. Test verification (Release, --no-build)
Status: ✅ SUCCESS
Passed projects:
- tests/Architecture.Tests/Architecture.Tests.csproj
- tests/Domain.Tests/Domain.Tests.csproj
- tests/Web.Tests/Web.Tests.csproj
- tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj
- tests/Web.Tests.Integration/Web.Tests.Integration.csproj
- tests/AppHost.Tests/AppHost.Tests.csproj

REMAINING BLOCKER
-----------------
None found for the SharpCompress / NU1902 issue after the package pin was
applied and verified.

================================================================================
END OF BUILD LOG
================================================================================
207 changes: 160 additions & 47 deletions src/Web/Features/UserManagement/Profile.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@page "/profile"
@using System.Security.Claims
@using System.Text.Json
@using MyBlog.Web.Security
@attribute [Authorize]

Expand All @@ -18,13 +19,13 @@ else
<div class="flex flex-col items-center gap-4 text-center sm:flex-row sm:items-start sm:text-left">
@if (!string.IsNullOrWhiteSpace(_pictureUrl))
{
<img src="@_pictureUrl"
alt="@($"{_displayName} profile image")"
class="h-24 w-24 rounded-full border-2 border-primary-300 object-cover shadow-md dark:border-primary-700" />
<img src="@_pictureUrl" alt="@($"{_displayName} profile image")"
class="h-24 w-24 rounded-full border-2 border-primary-300 object-cover shadow-md dark:border-primary-700" />
}
else
{
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-primary-600 text-3xl font-bold text-primary-50 shadow-md dark:bg-primary-700">
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-primary-600 text-3xl font-bold text-primary-50 shadow-md dark:bg-primary-700">
@_initials
</div>
}
Expand All @@ -34,8 +35,9 @@ else
<h2 class="!text-2xl">@_displayName</h2>
@if (_isAdmin)
{
<span class="rounded-full bg-red-600 px-3 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-sm dark:bg-red-700"
title="Administrator">Admin</span>
<span
class="rounded-full bg-red-600 px-3 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-sm dark:bg-red-700"
title="Administrator">Admin</span>
}
</div>
<p class="!text-base !font-medium text-gray-600 dark:text-gray-300">@_emailAddress</p>
Expand All @@ -47,7 +49,8 @@ else
</div>

<div class="mt-6 grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-primary-200 bg-primary-50/70 p-4 dark:border-primary-800 dark:bg-primary-950/40">
<div
class="rounded-lg border border-primary-200 bg-primary-50/70 p-4 dark:border-primary-800 dark:bg-primary-950/40">
<h3 class="mb-2 !text-base">Identity</h3>
<dl class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<div>
Expand All @@ -61,7 +64,8 @@ else
</dl>
</div>

<div class="rounded-lg border border-primary-200 bg-primary-50/70 p-4 dark:border-primary-800 dark:bg-primary-950/40">
<div
class="rounded-lg border border-primary-200 bg-primary-50/70 p-4 dark:border-primary-800 dark:bg-primary-950/40">
<h3 class="mb-2 !text-base">Roles</h3>
@if (_roles.Count > 0)
{
Expand All @@ -70,11 +74,13 @@ else
{
@if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase))
{
<span class="rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-300">@role</span>
<span
class="rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-300">@role</span>
}
else
{
<span class="rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-800 dark:bg-green-900/40 dark:text-green-300">@role</span>
<span
class="rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-800 dark:bg-green-900/40 dark:text-green-300">@role</span>
}
}
</div>
Expand All @@ -90,40 +96,45 @@ else
<section class="card overflow-hidden">
<div class="border-b border-primary-200 px-6 py-4 dark:border-primary-800">
<h2 class="!text-xl">Claims</h2>
<p class="!mt-1 !text-sm !font-medium text-gray-600 dark:text-gray-300">Claims currently present on your authenticated user principal.</p>
</div>

@if (_claims.Count == 0)
{
<div class="px-6 py-4">
<p class="!text-sm !font-medium text-gray-600 dark:text-gray-300">No claims were found.</p>
<p class="!mt-1 !text-sm !font-medium text-gray-600 dark:text-gray-300">Claims currently present on your
authenticated user principal.</p>
</div>
}
else
{
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-primary-200 dark:divide-primary-800">
<thead class="bg-primary-50 dark:bg-primary-950/60">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">Claim Type</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-primary-100 dark:divide-primary-900/80">
@foreach (var claim in _claims)
{
<tr class="align-top odd:bg-white odd:dark:bg-gray-900 even:bg-primary-50/40 even:dark:bg-primary-950/20">
<td class="px-4 py-3 text-sm font-semibold text-gray-800 dark:text-gray-200 break-all">@claim.Type</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 break-all">@claim.Value</td>
</tr>
}
</tbody>
</table>

@if (_claims.Count == 0)
{
<div class="px-6 py-4">
<p class="!text-sm !font-medium text-gray-600 dark:text-gray-300">No claims were found.</p>
</div>
}
else
{
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-primary-200 dark:divide-primary-800">
<thead class="bg-primary-50 dark:bg-primary-950/60">
<tr>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Claim Type</th>
<th
class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
Value</th>
</tr>
</thead>
<tbody class="divide-y divide-primary-100 dark:divide-primary-900/80">
@foreach (var claim in _claims)
{
<tr class="align-top odd:bg-white odd:dark:bg-gray-900 even:bg-primary-50/40 even:dark:bg-primary-950/20">
<td class="px-4 py-3 text-sm font-semibold text-gray-800 dark:text-gray-200 break-all">@claim.Type</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300 break-all">@claim.Value</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
</div>
}
</section>
</div>
}

@code {
[CascadingParameter]
Expand All @@ -139,7 +150,7 @@ else
private IReadOnlyList<string> _roles = [];
private IReadOnlyList<Claim> _claims = [];

private static readonly HashSet<string> _sensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase)
private static readonly HashSet<string> SensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase)
{
"nonce", "c_hash", "at_hash", "aud", "azp", "auth_time", "iat", "exp", "nbf"
};
Expand All @@ -161,9 +172,7 @@ else
"nickname",
ClaimTypes.GivenName) ?? _user.Identity?.Name ?? "Unknown User";

_emailAddress = GetFirstClaimValue(_user,
ClaimTypes.Email,
"email") ?? "No email claim found";
_emailAddress = GetEmailAddress(_user) ?? "No email claim found";

_userId = GetFirstClaimValue(_user,
ClaimTypes.NameIdentifier,
Expand All @@ -178,12 +187,31 @@ else
_roles = RoleClaimsHelper.GetRoles(_user);
_isAdmin = _roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
_claims = _user.Claims
.Where(c => !_sensitiveClaimTypes.Contains(c.Type))
.Where(c => !SensitiveClaimTypes.Contains(c.Type))
.OrderBy(c => c.Type)
.ThenBy(c => c.Value)
.ToList();
}

private static string? GetEmailAddress(ClaimsPrincipal user)
{
string? directEmail = GetFirstClaimValueMatching(user,
static claim => claim.Type.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase)
|| claim.Type.Equals("email", StringComparison.OrdinalIgnoreCase));

if (!string.IsNullOrWhiteSpace(directEmail))
{
return directEmail;
}

return GetFirstClaimValueMatching(user,
static claim => claim.Type.Equals(ClaimTypes.Upn, StringComparison.OrdinalIgnoreCase)
|| claim.Type.Equals("upn", StringComparison.OrdinalIgnoreCase)
|| claim.Type.Equals("preferred_username", StringComparison.OrdinalIgnoreCase)
|| claim.Type.Equals("emails", StringComparison.OrdinalIgnoreCase)
|| IsEmailClaimType(claim.Type));
}

private static string? GetFirstClaimValue(ClaimsPrincipal user, params string[] claimTypes)
{
foreach (var claimType in claimTypes)
Expand All @@ -200,9 +228,94 @@ else
}


private static string? GetFirstClaimValueMatching(ClaimsPrincipal user, Func<Claim, bool> predicate)
{
foreach (Claim claim in user.Claims.Where(predicate))
{
foreach (string value in GetClaimValues(claim.Value))
{
if (LooksLikeEmailAddress(value))
{
return value;
}
}
}

return null;
}

private static IReadOnlyList<string> GetClaimValues(string? claimValue)
{
if (string.IsNullOrWhiteSpace(claimValue))
{
return [];
}

string trimmed = claimValue.Trim();

if (trimmed.StartsWith("[", StringComparison.Ordinal))
{
try
{
using JsonDocument document = JsonDocument.Parse(trimmed);

if (document.RootElement.ValueKind == JsonValueKind.Array)
{
return document.RootElement
.EnumerateArray()
.Where(static element => element.ValueKind == JsonValueKind.String)
.Select(static element => element.GetString())
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.ToArray();
Comment thread
mpaulosky marked this conversation as resolved.
}
}
catch (JsonException)
{
return [];
}
}

if (trimmed.Contains(',', StringComparison.Ordinal))
{
return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}

return [trimmed];
}

private static bool IsEmailClaimType(string? claimType)
{
if (string.IsNullOrWhiteSpace(claimType))
{
return false;
}

string tail = GetClaimTypeTail(claimType);
return tail.Equals("email", StringComparison.OrdinalIgnoreCase)
|| tail.Equals("emails", StringComparison.OrdinalIgnoreCase);
}

private static string GetClaimTypeTail(string claimType)
{
int lastSlash = claimType.LastIndexOf('/');
int lastColon = claimType.LastIndexOf(':');
int separatorIndex = Math.Max(lastSlash, lastColon);

return separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType;
}

private static bool LooksLikeEmailAddress(string? value)
{
return !string.IsNullOrWhiteSpace(value)
&& value.Contains('@', StringComparison.Ordinal)
&& !value.Contains(' ', StringComparison.Ordinal);
}

private static string GetInitials(string displayName, string emailAddress)
{
var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", StringComparison.OrdinalIgnoreCase)
var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User",
StringComparison.OrdinalIgnoreCase)
? displayName
: emailAddress;

Expand Down
Loading
Loading