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
Expand Up @@ -35,7 +35,7 @@ public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, Cance
foreach (var entry in base.ChangeTracker.Entries<BaseDomainEntity>()
.Where(q => q.State == EntityState.Added || q.State == EntityState.Modified))
{
string userId = (_currentUser == null || _currentUser.Id == null ? "System" : _currentUser.Id.ToString());
string userId = _currentUser?.Id ?? "System";

entry.Entity.DateLastModified = _systemTime.Now;
entry.Entity.LastModifiedBy = userId;
Expand Down
90 changes: 55 additions & 35 deletions Src/RCommon.Security/Claims/ClaimTypesConst.cs
Original file line number Diff line number Diff line change
@@ -1,56 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

namespace RCommon.Security.Claims
{
/// <summary>
/// Provides configurable constants for standard claim type URIs used throughout the security subsystem.
/// Each property defaults to the corresponding <see cref="ClaimTypes"/> value and can be overridden at startup.
/// Provides claim type URI constants used throughout the security subsystem.
/// Call <see cref="Configure"/> once at startup to override defaults.
/// After configuration (or first property access), values are frozen.
/// </summary>
public static class ClaimTypesConst
{
/// <summary>
/// Default: <see cref="ClaimTypes.Name"/>
/// </summary>
public static string UserName { get; set; } = ClaimTypes.Name;
private static ClaimTypesOptions? _options;
private static bool _frozen;
private static readonly object _lock = new();

/// <summary>
/// Default: <see cref="ClaimTypes.GivenName"/>
/// </summary>
public static string Name { get; set; } = ClaimTypes.GivenName;
public static string UserName => GetOptions().UserName;
public static string Name => GetOptions().Name;
public static string SurName => GetOptions().SurName;
public static string UserId => GetOptions().UserId;
public static string Role => GetOptions().Role;
public static string Email => GetOptions().Email;
public static string TenantId => GetOptions().TenantId;
public static string ClientId => GetOptions().ClientId;

/// <summary>
/// Default: <see cref="ClaimTypes.Surname"/>
/// Configures claim type mappings. May only be called once, before any property is accessed.
/// </summary>
public static string SurName { get; set; } = ClaimTypes.Surname;
public static void Configure(Action<ClaimTypesOptions> configure)
{
Guard.IsNotNull(configure, nameof(configure));

/// <summary>
/// Default: <see cref="ClaimTypes.NameIdentifier"/>
/// </summary>
public static string UserId { get; set; } = ClaimTypes.NameIdentifier;
lock (_lock)
{
if (_frozen)
{
throw new InvalidOperationException(
"ClaimTypesConst has already been configured or accessed. Configure may only be called once, before any property is read.");
}

/// <summary>
/// Default: <see cref="ClaimTypes.Role"/>
/// </summary>
public static string Role { get; set; } = ClaimTypes.Role;
var options = new ClaimTypesOptions();
configure(options);
_options = options;
_frozen = true;
}
}

/// <summary>
/// Default: <see cref="ClaimTypes.Email"/>
/// </summary>
public static string Email { get; set; } = ClaimTypes.Email;
private static ClaimTypesOptions GetOptions()
{
if (_options != null)
return _options;

/// <summary>
/// Default: "tenantid".
/// </summary>
public static string TenantId { get; set; } = "tenantid";
lock (_lock)
{
if (_options != null)
return _options;

_options = new ClaimTypesOptions();
_frozen = true;
return _options;
}
}

/// <summary>
/// Default: "client_id".
/// Resets configuration to allow reconfiguration. Internal — for test isolation only.
/// </summary>
public static string ClientId { get; set; } = "client_id";
internal static void Reset()
{
lock (_lock)
{
_options = null;
_frozen = false;
}
}
}
}
20 changes: 20 additions & 0 deletions Src/RCommon.Security/Claims/ClaimTypesOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Security.Claims;

namespace RCommon.Security.Claims
{
/// <summary>
/// Configurable options for claim type URI mappings.
/// Set properties to match the claim types issued by your identity provider.
/// </summary>
public class ClaimTypesOptions
{
public string UserName { get; set; } = ClaimTypes.Name;
public string Name { get; set; } = ClaimTypes.GivenName;
public string SurName { get; set; } = ClaimTypes.Surname;
public string UserId { get; set; } = ClaimTypes.NameIdentifier;
public string Role { get; set; } = ClaimTypes.Role;
public string Email { get; set; } = ClaimTypes.Email;
public string TenantId { get; set; } = "tenantid";
public string ClientId { get; set; } = "client_id";
}
}
26 changes: 8 additions & 18 deletions Src/RCommon.Security/ClaimsIdentityExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ namespace RCommon.Security
public static class ClaimsIdentityExtensions
{
/// <summary>
/// Extracts the user identifier from the principal's claims as a <see cref="Guid"/>.
/// Extracts the user identifier from the principal's claims as a raw string value.
/// </summary>
/// <param name="principal">The claims principal to search.</param>
/// <returns>The parsed user ID, or <c>null</c> if the claim is missing or not a valid GUID.</returns>
public static Guid? FindUserId(this ClaimsPrincipal principal)
/// <returns>The user ID string, or <c>null</c> if the claim is missing or empty.</returns>
public static string? FindUserId(this ClaimsPrincipal principal)
{
Guard.IsNotNull(principal, nameof(principal));

Expand All @@ -30,20 +30,15 @@ public static class ClaimsIdentityExtensions
return null;
}

if (Guid.TryParse(userIdOrNull.Value, out Guid guid))
{
return guid;
}

return null;
return userIdOrNull.Value;
}

/// <summary>
/// Extracts the user identifier from the identity's claims as a <see cref="Guid"/>.
/// Extracts the user identifier from the identity's claims as a raw string value.
/// </summary>
/// <param name="identity">The identity to search. Must be castable to <see cref="ClaimsIdentity"/>.</param>
/// <returns>The parsed user ID, or <c>null</c> if the claim is missing or not a valid GUID.</returns>
public static Guid? FindUserId(this IIdentity identity)
/// <returns>The user ID string, or <c>null</c> if the claim is missing or empty.</returns>
public static string? FindUserId(this IIdentity identity)
{
Guard.IsNotNull(identity, nameof(identity));

Expand All @@ -55,12 +50,7 @@ public static class ClaimsIdentityExtensions
return null;
}

if (Guid.TryParse(userIdOrNull.Value, out var guid))
{
return guid;
}

return null;
return userIdOrNull.Value;
}

/// <summary>
Expand Down
4 changes: 4 additions & 0 deletions Src/RCommon.Security/RCommon.Security.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@
<PackageReference Update="MinVer" Version="7.0.0" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="RCommon.Security.Tests" />
</ItemGroup>

</Project>
4 changes: 2 additions & 2 deletions Src/RCommon.Security/Users/CurrentUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ public CurrentUser(ICurrentPrincipalAccessor principalAccessor)
}

/// <inheritdoc />
public virtual bool IsAuthenticated => Id.HasValue;
public virtual bool IsAuthenticated => _principalAccessor.Principal?.Identity?.IsAuthenticated ?? false;

/// <inheritdoc />
public virtual Guid? Id => _principalAccessor.Principal?.FindUserId();
public virtual string? Id => _principalAccessor.Principal?.FindUserId();

/// <inheritdoc />
public virtual string? TenantId => _principalAccessor.Principal?.FindTenantId();
Expand Down
39 changes: 5 additions & 34 deletions Src/RCommon.Security/Users/CurrentUserExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
using RCommon.Security.Claims;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System;

namespace RCommon.Security.Users
{
Expand All @@ -26,38 +19,16 @@ public static class CurrentUserExtensions
return currentUser.FindClaim(claimType)?.Value;
}

/// <summary>
/// Finds a claim of the specified type and converts its value to <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The target value type to convert the claim value to.</typeparam>
/// <param name="currentUser">The current user instance.</param>
/// <param name="claimType">The claim type URI to search for.</param>
/// <returns>The converted claim value, or <c>default</c> if the claim is not found.</returns>
/// <remarks>Uses <see cref="Convert.ChangeType(object, Type, IFormatProvider)"/> with <see cref="CultureInfo.InvariantCulture"/>.</remarks>
public static T FindClaimValue<T>(this ICurrentUser currentUser, string claimType)
where T : struct
{
var value = currentUser.FindClaimValue(claimType);
if (value == null)
{
return default;
}
return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
}

/// <summary>
/// Gets the current user's ID, asserting that it is not <c>null</c>.
/// </summary>
/// <param name="currentUser">The current user instance.</param>
/// <returns>The user's <see cref="Guid"/> identifier.</returns>
/// <returns>The user's string identifier.</returns>
/// <exception cref="InvalidOperationException">Thrown if <see cref="ICurrentUser.Id"/> is <c>null</c>.</exception>
public static Guid GetId(this ICurrentUser currentUser)
public static string GetId(this ICurrentUser currentUser)
{
Debug.Assert(currentUser.Id != null, "currentUser.Id != null");

return currentUser.Id.Value;
return currentUser.Id
?? throw new InvalidOperationException("The current user ID is null. Ensure the user is authenticated and has a NameIdentifier claim.");
}


}
}
7 changes: 3 additions & 4 deletions Src/RCommon.Security/Users/ICurrentUser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Security.Claims;
using System.Security.Claims;

namespace RCommon.Security.Users
{
Expand All @@ -11,10 +10,10 @@ public interface ICurrentUser
/// <summary>
/// Gets the unique identifier of the current user, or <c>null</c> if no user is authenticated.
/// </summary>
Guid? Id { get; }
string? Id { get; }

/// <summary>
/// Gets a value indicating whether the current user is authenticated (i.e., <see cref="Id"/> is not <c>null</c>).
/// Gets a value indicating whether the current user is authenticated, as reported by the underlying <see cref="System.Security.Principal.IIdentity"/>.
/// </summary>
bool IsAuthenticated { get; }

Expand Down
67 changes: 67 additions & 0 deletions Tests/RCommon.Security.Tests/ClaimTypesConstTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Security.Claims;
using FluentAssertions;
using RCommon.Security.Claims;
using Xunit;

namespace RCommon.Security.Tests;

public class ClaimTypesConstTests : IDisposable
{
public ClaimTypesConstTests()
{
ClaimTypesConst.Reset();
}

public void Dispose()
{
ClaimTypesConst.Reset();
}

[Fact]
public void Defaults_MatchStandardClaimTypes()
{
ClaimTypesConst.UserId.Should().Be(ClaimTypes.NameIdentifier);
ClaimTypesConst.UserName.Should().Be(ClaimTypes.Name);
ClaimTypesConst.Name.Should().Be(ClaimTypes.GivenName);
ClaimTypesConst.SurName.Should().Be(ClaimTypes.Surname);
ClaimTypesConst.Role.Should().Be(ClaimTypes.Role);
ClaimTypesConst.Email.Should().Be(ClaimTypes.Email);
ClaimTypesConst.TenantId.Should().Be("tenantid");
ClaimTypesConst.ClientId.Should().Be("client_id");
}

[Fact]
public void Configure_AppliesCustomValues()
{
ClaimTypesConst.Configure(options =>
{
options.UserId = "sub";
options.Role = "roles";
options.TenantId = "tenant_id";
});

ClaimTypesConst.UserId.Should().Be("sub");
ClaimTypesConst.Role.Should().Be("roles");
ClaimTypesConst.TenantId.Should().Be("tenant_id");
// Unchanged values keep defaults
ClaimTypesConst.Email.Should().Be(ClaimTypes.Email);
}

[Fact]
public void Configure_CalledTwice_ThrowsInvalidOperationException()
{
ClaimTypesConst.Configure(options => { options.UserId = "sub"; });

var action = () => ClaimTypesConst.Configure(options => { options.Role = "roles"; });

action.Should().Throw<InvalidOperationException>();
}

[Fact]
public void Configure_WithNullAction_ThrowsArgumentNullException()
{
var action = () => ClaimTypesConst.Configure(null!);

action.Should().Throw<ArgumentNullException>();
}
}
Loading
Loading