diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9f320cc..62b5134 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,11 +2,13 @@ name: CI
on:
push:
- branches-ignore: [] # build every branch push
pull_request:
branches: [ "main" ]
workflow_dispatch:
+permissions:
+ contents: read
+
# Cancel superseded runs on the same ref to save minutes.
concurrency:
group: ci-${{ github.ref }}
diff --git a/Authorization.sln b/Authorization.sln
index 4e844bf..c6d9e3a 100644
--- a/Authorization.sln
+++ b/Authorization.sln
@@ -7,8 +7,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Abstractions", "src\Authorization.Abstractions\Authorization.Abstractions.csproj", "{60BA81F8-B279-47E3-8786-6FAE86C81FD3}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Common", "src\Authorization.Common\Authorization.Common.csproj", "{E8AF7B80-0A65-45E5-8F73-357A0FFFF699}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.DataAccess", "src\Authorization.DataAccess\Authorization.DataAccess.csproj", "{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Business", "src\Authorization.Business\Authorization.Business.csproj", "{8B921C4E-CCBD-49DE-91F8-0955695BC2D7}"
@@ -39,18 +37,6 @@ Global
{60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x64.Build.0 = Release|Any CPU
{60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x86.ActiveCfg = Release|Any CPU
{60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x86.Build.0 = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x64.ActiveCfg = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x64.Build.0 = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x86.ActiveCfg = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x86.Build.0 = Debug|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|Any CPU.Build.0 = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x64.ActiveCfg = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x64.Build.0 = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x86.ActiveCfg = Release|Any CPU
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x86.Build.0 = Release|Any CPU
{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -105,7 +91,6 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{60BA81F8-B279-47E3-8786-6FAE86C81FD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
- {E8AF7B80-0A65-45E5-8F73-357A0FFFF699} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{8B921C4E-CCBD-49DE-91F8-0955695BC2D7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C3F978CB-98CB-46B9-82FB-D795B1D76DA4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
diff --git a/README.md b/README.md
index 7b52be0..895bc52 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Once a request has been authenticated (by JWT bearer auth, for example), the mid
1. Reads the configured user-name claim from the incoming principal.
2. Looks up the matching user in the security database (via a stored procedure).
-3. Adds `Email`, `Name` and `IdUsuario` claims.
+3. Adds `Email`, `Name` and `IdUsuario` (`ClaimsEnrichmentMiddleware.UserIdClaimType`) claims.
4. Looks up the user's profiles and adds a `Role` claim for each one.
If anything goes wrong resolving that data (missing claim, unknown user, database outage), the request **degrades gracefully**: it continues unenriched instead of crashing the pipeline.
@@ -29,9 +29,8 @@ The solution is layered so each concern is isolated and independently testable:
| Project | Responsibility |
| --- | --- |
-| `Authorization.Abstractions` | Contracts: entities, models, options and interfaces. No external dependencies. |
-| `Authorization.Common` | Cached, reflection-based object mapper used to map entities → models. |
-| `Authorization.DataAccess` | Dapper + `Microsoft.Data.SqlClient` access to stored procedures. |
+| `Authorization.Abstractions` | Contracts: models, options and interfaces. No external dependencies. |
+| `Authorization.DataAccess` | Dapper + `Microsoft.Data.SqlClient` access to stored procedures; maps database rows to models. |
| `Authorization.Business` | Thin business layer orchestrating identity resolution. |
| `Authorization.Middleware` | The ASP.NET Core middleware plus DI and pipeline extensions. |
@@ -92,7 +91,7 @@ app.UseAuthorization();
## ⚙️ Configuration
-Everything that used to be hard-coded is now configurable through `ClaimsEnrichmentOptions`:
+Claim and database settings are configurable through `ClaimsEnrichmentOptions`. They are validated at startup, so a missing connection string or blank setting fails the host instead of individual requests.
```csharp
builder.Services.AddAuthorizationClaims(options =>
diff --git a/src/Authorization.Abstractions/Authorization.Abstractions.csproj b/src/Authorization.Abstractions/Authorization.Abstractions.csproj
index acdd200..b5e6512 100644
--- a/src/Authorization.Abstractions/Authorization.Abstractions.csproj
+++ b/src/Authorization.Abstractions/Authorization.Abstractions.csproj
@@ -2,7 +2,7 @@
Authorization.Abstractions
- Contracts (entities, models, options and interfaces) for the JWT authorization middleware. Has no external dependencies.
+ Contracts (models, options and interfaces) for the JWT authorization middleware. Has no external dependencies.
diff --git a/src/Authorization.Abstractions/Entities/Profile.cs b/src/Authorization.Abstractions/Entities/Profile.cs
deleted file mode 100644
index b966ead..0000000
--- a/src/Authorization.Abstractions/Entities/Profile.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace Authorization.Abstractions.Entities;
-
-///
-/// Database-facing representation of a security profile (role) a user can hold.
-///
-public sealed class Profile
-{
- /// Unique identifier of the profile.
- public int Id { get; set; }
-
- /// Display name of the profile.
- public string? Name { get; set; }
-}
diff --git a/src/Authorization.Abstractions/Entities/User.cs b/src/Authorization.Abstractions/Entities/User.cs
deleted file mode 100644
index d6ab7f9..0000000
--- a/src/Authorization.Abstractions/Entities/User.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-namespace Authorization.Abstractions.Entities;
-
-///
-/// Database-facing representation of a user, as returned by the security data store.
-///
-public sealed class User
-{
- /// Unique identifier of the user.
- public Guid Id { get; set; }
-
- /// Login name of the user.
- public string? UserName { get; set; }
-
- /// Hashed password. Never expose this outside the data layer.
- public string? PasswordHash { get; set; }
-
- /// Email address of the user.
- public string? Email { get; set; }
-}
diff --git a/src/Authorization.Abstractions/Models/User.cs b/src/Authorization.Abstractions/Models/User.cs
index 4cb82ea..b98a840 100644
--- a/src/Authorization.Abstractions/Models/User.cs
+++ b/src/Authorization.Abstractions/Models/User.cs
@@ -2,8 +2,6 @@ namespace Authorization.Abstractions.Models;
///
/// Domain model of a user, used by the business and middleware layers.
-/// Kept separate from the database entity so persistence changes never leak
-/// into the pipeline contract.
///
public sealed class User
{
@@ -13,9 +11,6 @@ public sealed class User
/// Login name of the user.
public string? UserName { get; set; }
- /// Hashed password. Never surfaced in claims, logs or responses.
- public string? PasswordHash { get; set; }
-
/// Email address of the user.
public string? Email { get; set; }
}
diff --git a/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs b/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs
index a8f2515..249c914 100644
--- a/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs
+++ b/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs
@@ -2,9 +2,6 @@ namespace Authorization.Abstractions.Options;
///
/// Configuration for the claims-enrichment middleware and the security store.
-/// Everything that used to be hard-coded (the inbound claim to read the user
-/// name from, and the stored-procedure names) is configurable here so the
-/// package can be reused without recompiling.
///
public sealed class ClaimsEnrichmentOptions
{
@@ -16,19 +13,19 @@ public sealed class ClaimsEnrichmentOptions
///
/// The inbound JWT claim type that carries the user name used to look the
- /// user up. Defaults to usuario to preserve the historical contract.
+ /// user up. Defaults to usuario.
///
public string UserNameClaimType { get; set; } = "usuario";
///
/// Stored procedure that returns a single user by user name / email.
- /// Defaults to ObtenerUsuario (the existing database object name).
+ /// Defaults to ObtenerUsuario.
///
public string GetUserProcedure { get; set; } = "ObtenerUsuario";
///
/// Stored procedure that returns the profiles for a user.
- /// Defaults to ObtenerPerfilesxUsuario (the existing database object name).
+ /// Defaults to ObtenerPerfilesxUsuario.
///
public string GetProfilesProcedure { get; set; } = "ObtenerPerfilesxUsuario";
}
diff --git a/src/Authorization.Common/Authorization.Common.csproj b/src/Authorization.Common/Authorization.Common.csproj
deleted file mode 100644
index fc3d216..0000000
--- a/src/Authorization.Common/Authorization.Common.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
- Authorization.Common
- Shared helpers for the JWT authorization middleware, including a cached reflection-based object mapper.
-
-
-
diff --git a/src/Authorization.Common/Converter.cs b/src/Authorization.Common/Converter.cs
deleted file mode 100644
index 2435761..0000000
--- a/src/Authorization.Common/Converter.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-namespace Authorization.Common;
-
-///
-/// Convenience facade over for single objects and sequences.
-///
-public static class Converter
-{
- /// Creates a shallow copy of .
- public static TModel? Clone(TModel? source)
- where TModel : class, new()
- => Mapper.Map(source);
-
- /// Converts a single object to .
- public static TDestination? Convert(
- TSource? source,
- Action? transform = null)
- where TSource : class
- where TDestination : new()
- => Mapper.Map(source, transform);
-
- ///
- /// Converts a sequence, skipping any elements that map to null.
- /// Materialised to a list so the (already-executed) source query is only
- /// enumerated once.
- ///
- public static IReadOnlyList ConvertList(
- IEnumerable source,
- Action? transform = null)
- where TSource : class
- where TDestination : new()
- {
- ArgumentNullException.ThrowIfNull(source);
-
- var result = new List();
- foreach (var element in source)
- {
- var mapped = Mapper.Map(element, transform);
- if (mapped is not null)
- {
- result.Add(mapped);
- }
- }
-
- return result;
- }
-}
diff --git a/src/Authorization.Common/Mapper.cs b/src/Authorization.Common/Mapper.cs
deleted file mode 100644
index 0784255..0000000
--- a/src/Authorization.Common/Mapper.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System.Collections.Concurrent;
-using System.Reflection;
-
-namespace Authorization.Common;
-
-///
-/// Lightweight convention-based object mapper: copies matching public
-/// properties (by name and type) from a source object to a new destination
-/// instance.
-///
-///
-/// This runs on the request hot path, so the reflection work (discovering
-/// which source/destination properties line up) is computed once per
-/// type-pair and cached. Only value types, strings and arrays are copied,
-/// mirroring the original shallow-copy behaviour so nested reference graphs
-/// are never shared by accident.
-///
-public static class Mapper
-{
- private static readonly ConcurrentDictionary<(Type Source, Type Destination), PropertyPair[]> PropertyMapCache = new();
-
- private readonly record struct PropertyPair(PropertyInfo Source, PropertyInfo Destination);
-
- ///
- /// Maps onto a new instance of
- /// .
- ///
- /// Object to read values from. May be null.
- /// Optional hook to apply custom rules after the automatic copy.
- /// The populated destination, or the type default when is null.
- public static TDestination? Map(
- TSource? source,
- Action? transform = null)
- where TSource : class
- where TDestination : new()
- {
- if (source is null)
- {
- return default;
- }
-
- var destination = new TDestination();
- foreach (var pair in GetPropertyMap(typeof(TSource), typeof(TDestination)))
- {
- pair.Destination.SetValue(destination, pair.Source.GetValue(source));
- }
-
- transform?.Invoke(source, destination);
- return destination;
- }
-
- private static PropertyPair[] GetPropertyMap(Type source, Type destination) =>
- PropertyMapCache.GetOrAdd((source, destination), static key => BuildPropertyMap(key.Source, key.Destination));
-
- private static PropertyPair[] BuildPropertyMap(Type source, Type destination)
- {
- var destinationProperties = destination
- .GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .ToDictionary(p => p.Name, StringComparer.Ordinal);
-
- var pairs = new List();
- foreach (var sourceProperty in source.GetProperties(BindingFlags.Public | BindingFlags.Instance))
- {
- if (!destinationProperties.TryGetValue(sourceProperty.Name, out var destinationProperty))
- {
- continue;
- }
-
- if (!destinationProperty.CanWrite ||
- destinationProperty.GetIndexParameters().Length != 0 ||
- destinationProperty.PropertyType != sourceProperty.PropertyType ||
- !IsCopyable(destinationProperty.PropertyType))
- {
- continue;
- }
-
- pairs.Add(new PropertyPair(sourceProperty, destinationProperty));
- }
-
- return pairs.ToArray();
- }
-
- // Copy value types, strings and arrays only; skip complex reference types
- // to avoid sharing mutable nested objects between source and destination.
- private static bool IsCopyable(Type type) =>
- !type.IsClass || type == typeof(string) || type.IsArray;
-}
diff --git a/src/Authorization.DataAccess/Authorization.DataAccess.csproj b/src/Authorization.DataAccess/Authorization.DataAccess.csproj
index b3485c2..6bf0230 100644
--- a/src/Authorization.DataAccess/Authorization.DataAccess.csproj
+++ b/src/Authorization.DataAccess/Authorization.DataAccess.csproj
@@ -14,7 +14,6 @@
-
diff --git a/src/Authorization.DataAccess/Entities/Profile.cs b/src/Authorization.DataAccess/Entities/Profile.cs
new file mode 100644
index 0000000..3511863
--- /dev/null
+++ b/src/Authorization.DataAccess/Entities/Profile.cs
@@ -0,0 +1,8 @@
+namespace Authorization.DataAccess.Entities;
+
+internal sealed class Profile
+{
+ public int Id { get; set; }
+
+ public string? Name { get; set; }
+}
diff --git a/src/Authorization.DataAccess/Entities/User.cs b/src/Authorization.DataAccess/Entities/User.cs
new file mode 100644
index 0000000..955d54c
--- /dev/null
+++ b/src/Authorization.DataAccess/Entities/User.cs
@@ -0,0 +1,13 @@
+namespace Authorization.DataAccess.Entities;
+
+// Row shape returned by the user stored procedure. Columns without a matching
+// property (e.g. the password hash) are ignored by Dapper, so secrets are never
+// materialized in memory.
+internal sealed class User
+{
+ public Guid Id { get; set; }
+
+ public string? UserName { get; set; }
+
+ public string? Email { get; set; }
+}
diff --git a/src/Authorization.DataAccess/SecurityRepository.cs b/src/Authorization.DataAccess/SecurityRepository.cs
index a624d76..9a015f5 100644
--- a/src/Authorization.DataAccess/SecurityRepository.cs
+++ b/src/Authorization.DataAccess/SecurityRepository.cs
@@ -1,10 +1,9 @@
using System.Data;
using Authorization.Abstractions.DataAccess;
using Authorization.Abstractions.Options;
-using Authorization.Common;
using Dapper;
using Microsoft.Extensions.Options;
-using Entities = Authorization.Abstractions.Entities;
+using Entities = Authorization.DataAccess.Entities;
using Models = Authorization.Abstractions.Models;
namespace Authorization.DataAccess;
@@ -29,19 +28,11 @@ public SecurityRepository(IDbConnectionFactory connectionFactory, IOptions(
+ CreateCommand(_options.GetUserProcedure, user, cancellationToken));
- var command = new CommandDefinition(
- _options.GetUserProcedure,
- new { user.Email, user.UserName },
- commandType: CommandType.StoredProcedure,
- cancellationToken: cancellationToken);
-
- var entity = await connection.QueryFirstOrDefaultAsync(command);
- return Converter.Convert(entity);
+ return entity is null ? null : ToModel(entity);
}
///
@@ -50,14 +41,21 @@ public SecurityRepository(IDbConnectionFactory connectionFactory, IOptions(
+ CreateCommand(_options.GetProfilesProcedure, user, cancellationToken));
- var command = new CommandDefinition(
- _options.GetProfilesProcedure,
+ return entities.Select(ToModel).ToList();
+ }
+
+ private static CommandDefinition CreateCommand(string procedure, Models.User user, CancellationToken cancellationToken) =>
+ new(procedure,
new { user.Email, user.UserName },
commandType: CommandType.StoredProcedure,
cancellationToken: cancellationToken);
- var entities = await connection.QueryAsync(command);
- return Converter.ConvertList(entities);
- }
+ private static Models.User ToModel(Entities.User entity) =>
+ new() { Id = entity.Id, UserName = entity.UserName, Email = entity.Email };
+
+ private static Models.Profile ToModel(Entities.Profile entity) =>
+ new() { Id = entity.Id, Name = entity.Name };
}
diff --git a/src/Authorization.Middleware/Authorization.Middleware.csproj b/src/Authorization.Middleware/Authorization.Middleware.csproj
index e744ddd..b1179b3 100644
--- a/src/Authorization.Middleware/Authorization.Middleware.csproj
+++ b/src/Authorization.Middleware/Authorization.Middleware.csproj
@@ -6,17 +6,12 @@
-
-
-
-
-
+
-
diff --git a/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs b/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs
index cb42eef..ec3d173 100644
--- a/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs
+++ b/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs
@@ -21,6 +21,9 @@ namespace Authorization.Middleware;
///
public sealed partial class ClaimsEnrichmentMiddleware
{
+ /// Claim type carrying the resolved user's identifier.
+ public const string UserIdClaimType = "IdUsuario";
+
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly ClaimsEnrichmentOptions _options;
@@ -102,10 +105,10 @@ private static void AddUserClaims(ICollection claims, User user)
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
}
- claims.Add(new Claim("IdUsuario", user.Id.ToString()));
+ claims.Add(new Claim(UserIdClaimType, user.Id.ToString()));
}
- private async Task AddProfileClaimsAsync(
+ private static async Task AddProfileClaimsAsync(
ICollection claims,
User user,
IAuthorizationManager authorizationManager,
diff --git a/src/Authorization.Middleware/ServiceCollectionExtensions.cs b/src/Authorization.Middleware/ServiceCollectionExtensions.cs
index 042c11e..c8acefe 100644
--- a/src/Authorization.Middleware/ServiceCollectionExtensions.cs
+++ b/src/Authorization.Middleware/ServiceCollectionExtensions.cs
@@ -3,14 +3,13 @@
using Authorization.Abstractions.Options;
using Authorization.Business;
using Authorization.DataAccess;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Authorization.Middleware;
///
-/// Dependency-injection registration for the authorization stack. A single
-/// call wires the connection factory, repository and business manager so
-/// consumers no longer have to assemble the graph by hand.
+/// Dependency-injection registration for the authorization stack.
///
public static class ServiceCollectionExtensions
{
@@ -25,11 +24,17 @@ public static IServiceCollection AddAuthorizationClaims(
{
ArgumentNullException.ThrowIfNull(services);
- var optionsBuilder = services.AddOptions();
- if (configure is not null)
- {
- optionsBuilder.Configure(configure);
- }
+ // Validated at host start: the connection factory is resolved while
+ // binding the middleware's scoped dependencies, outside its graceful
+ // degradation, so a misconfiguration would otherwise fail every request.
+ services.AddOptions()
+ .Configure(options => configure?.Invoke(options))
+ .Validate(HasRequiredSettings,
+ "ClaimsEnrichmentOptions requires a connection string name, user name claim type and stored procedure names.")
+ .Validate(
+ (options, configuration) => !string.IsNullOrWhiteSpace(configuration.GetConnectionString(options.ConnectionStringName)),
+ "The security database connection string was not found under \"ConnectionStrings\". Check ClaimsEnrichmentOptions.ConnectionStringName (default: SecurityDb).")
+ .ValidateOnStart();
// The factory only caches an immutable connection string, so it is safe
// as a singleton; it still hands out a fresh connection per call.
@@ -39,4 +44,10 @@ public static IServiceCollection AddAuthorizationClaims(
return services;
}
+
+ private static bool HasRequiredSettings(ClaimsEnrichmentOptions options) =>
+ !string.IsNullOrWhiteSpace(options.ConnectionStringName) &&
+ !string.IsNullOrWhiteSpace(options.UserNameClaimType) &&
+ !string.IsNullOrWhiteSpace(options.GetUserProcedure) &&
+ !string.IsNullOrWhiteSpace(options.GetProfilesProcedure);
}
diff --git a/tests/Authorization.UnitTests/Authorization.UnitTests.csproj b/tests/Authorization.UnitTests/Authorization.UnitTests.csproj
index dc14bcf..9e67b5d 100644
--- a/tests/Authorization.UnitTests/Authorization.UnitTests.csproj
+++ b/tests/Authorization.UnitTests/Authorization.UnitTests.csproj
@@ -25,7 +25,6 @@
-
diff --git a/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs b/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs
index 61e1afc..1404f28 100644
--- a/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs
+++ b/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs
@@ -22,7 +22,7 @@ public async Task Invoke_AnonymousUser_CallsNextWithoutEnrichment()
var context = new DefaultHttpContext();
var nextCalled = false;
- var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options);
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
await middleware.InvokeAsync(context, manager.Object);
@@ -45,14 +45,14 @@ public async Task Invoke_AuthenticatedUser_AddsUserAndRoleClaims()
var context = BuildAuthenticatedContext("jdoe");
var nextCalled = false;
- var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options);
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
await middleware.InvokeAsync(context, manager.Object);
Assert.True(nextCalled);
Assert.Equal("jdoe@example.com", context.User.FindFirst(ClaimTypes.Email)?.Value);
Assert.Equal("jdoe", context.User.FindFirst(ClaimTypes.Name)?.Value);
- Assert.Equal(userId.ToString(), context.User.FindFirst("IdUsuario")?.Value);
+ Assert.Equal(userId.ToString(), context.User.FindFirst(ClaimsEnrichmentMiddleware.UserIdClaimType)?.Value);
var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
Assert.Equal(new[] { "10", "20" }, roles);
}
@@ -62,7 +62,7 @@ public async Task Invoke_MissingUserNameClaim_DoesNotEnrich()
{
var manager = new Mock(MockBehavior.Strict);
var context = BuildAuthenticatedContext(userName: null);
- var middleware = new ClaimsEnrichmentMiddleware(_ => Task.CompletedTask, NullLogger.Instance, Options);
+ var middleware = CreateMiddleware(_ => Task.CompletedTask);
await middleware.InvokeAsync(context, manager.Object);
@@ -78,11 +78,11 @@ public async Task Invoke_UserNotFound_DoesNotAddClaims()
.ReturnsAsync((User?)null);
var context = BuildAuthenticatedContext("ghost");
- var middleware = new ClaimsEnrichmentMiddleware(_ => Task.CompletedTask, NullLogger.Instance, Options);
+ var middleware = CreateMiddleware(_ => Task.CompletedTask);
await middleware.InvokeAsync(context, manager.Object);
- Assert.Null(context.User.FindFirst("IdUsuario"));
+ Assert.Null(context.User.FindFirst(ClaimsEnrichmentMiddleware.UserIdClaimType));
manager.Verify(m => m.GetProfilesForUserAsync(It.IsAny(), It.IsAny()), Times.Never);
}
@@ -98,15 +98,39 @@ public async Task Invoke_StoreThrows_DegradesGracefullyAndCallsNext()
var context = BuildAuthenticatedContext("jdoe");
var nextCalled = false;
- var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options);
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context, manager.Object));
Assert.Null(exception);
Assert.True(nextCalled);
- Assert.Null(context.User.FindFirst("IdUsuario"));
+ Assert.Null(context.User.FindFirst(ClaimsEnrichmentMiddleware.UserIdClaimType));
}
+ [Fact]
+ public async Task Invoke_RequestAborted_SkipsEnrichmentAndCallsNext()
+ {
+ using var aborted = new CancellationTokenSource();
+ aborted.Cancel();
+ var manager = new Mock();
+ manager
+ .Setup(m => m.GetUserAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException(aborted.Token));
+
+ var context = BuildAuthenticatedContext("jdoe");
+ context.RequestAborted = aborted.Token;
+ var nextCalled = false;
+ var middleware = CreateMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
+
+ await middleware.InvokeAsync(context, manager.Object);
+
+ Assert.True(nextCalled);
+ Assert.Null(context.User.FindFirst(ClaimsEnrichmentMiddleware.UserIdClaimType));
+ }
+
+ private static ClaimsEnrichmentMiddleware CreateMiddleware(RequestDelegate next) =>
+ new(next, NullLogger.Instance, Options);
+
private static DefaultHttpContext BuildAuthenticatedContext(string? userName)
{
var claims = new List();
diff --git a/tests/Authorization.UnitTests/ConverterTests.cs b/tests/Authorization.UnitTests/ConverterTests.cs
deleted file mode 100644
index f3554e6..0000000
--- a/tests/Authorization.UnitTests/ConverterTests.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using Authorization.Common;
-using Entities = Authorization.Abstractions.Entities;
-using Models = Authorization.Abstractions.Models;
-
-namespace Authorization.UnitTests;
-
-public class ConverterTests
-{
- [Fact]
- public void ConvertList_MapsEveryElement()
- {
- var source = new[]
- {
- new Entities.Profile { Id = 1, Name = "admin" },
- new Entities.Profile { Id = 2, Name = "user" },
- };
-
- var result = Converter.ConvertList(source);
-
- Assert.Equal(2, result.Count);
- Assert.Equal("admin", result[0].Name);
- Assert.Equal("user", result[1].Name);
- }
-
- [Fact]
- public void ConvertList_EmptySource_ReturnsEmpty()
- {
- var result = Converter.ConvertList(Array.Empty());
- Assert.Empty(result);
- }
-
- [Fact]
- public void ConvertList_NullSource_Throws()
- {
- Assert.Throws(
- () => Converter.ConvertList(null!));
- }
-
- [Fact]
- public void Clone_ProducesIndependentCopy()
- {
- var original = new Models.User { Id = Guid.NewGuid(), UserName = "jdoe" };
-
- var clone = Converter.Clone(original);
-
- Assert.NotNull(clone);
- Assert.NotSame(original, clone);
- Assert.Equal(original.UserName, clone!.UserName);
- }
-}
diff --git a/tests/Authorization.UnitTests/MapperTests.cs b/tests/Authorization.UnitTests/MapperTests.cs
deleted file mode 100644
index 2736756..0000000
--- a/tests/Authorization.UnitTests/MapperTests.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Authorization.Common;
-using Entities = Authorization.Abstractions.Entities;
-using Models = Authorization.Abstractions.Models;
-
-namespace Authorization.UnitTests;
-
-public class MapperTests
-{
- [Fact]
- public void Map_CopiesMatchingProperties()
- {
- var id = Guid.NewGuid();
- var source = new Entities.User
- {
- Id = id,
- UserName = "jdoe",
- Email = "jdoe@example.com",
- PasswordHash = "hash",
- };
-
- var result = Mapper.Map(source);
-
- Assert.NotNull(result);
- Assert.Equal(id, result!.Id);
- Assert.Equal("jdoe", result.UserName);
- Assert.Equal("jdoe@example.com", result.Email);
- Assert.Equal("hash", result.PasswordHash);
- }
-
- [Fact]
- public void Map_NullSource_ReturnsDefault()
- {
- var result = Mapper.Map(null);
- Assert.Null(result);
- }
-
- [Fact]
- public void Map_AppliesTransformAfterCopy()
- {
- var source = new Entities.Profile { Id = 7, Name = "admin" };
-
- var result = Mapper.Map(
- source,
- (src, dest) => dest.Name = src.Name!.ToUpperInvariant());
-
- Assert.NotNull(result);
- Assert.Equal(7, result!.Id);
- Assert.Equal("ADMIN", result.Name);
- }
-
- [Fact]
- public void Map_IsConsistentAcrossCachedCalls()
- {
- // Exercises the per-type-pair property-map cache: a second call must
- // produce the same result as the first.
- var first = Mapper.Map(new Entities.Profile { Id = 1, Name = "a" });
- var second = Mapper.Map(new Entities.Profile { Id = 2, Name = "b" });
-
- Assert.Equal(1, first!.Id);
- Assert.Equal("a", first.Name);
- Assert.Equal(2, second!.Id);
- Assert.Equal("b", second.Name);
- }
-}
diff --git a/tests/Authorization.UnitTests/ServiceCollectionExtensionsTests.cs b/tests/Authorization.UnitTests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..f6a2b00
--- /dev/null
+++ b/tests/Authorization.UnitTests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,56 @@
+using Authorization.Abstractions.Options;
+using Authorization.Middleware;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Authorization.UnitTests;
+
+public class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void StartupValidation_MissingConnectionString_Throws()
+ {
+ using var provider = BuildProvider(new Dictionary());
+
+ var exception = Assert.Throws(
+ () => provider.GetRequiredService().Validate());
+
+ Assert.Contains("ConnectionStrings", exception.Message);
+ }
+
+ [Fact]
+ public void StartupValidation_BlankClaimType_Throws()
+ {
+ using var provider = BuildProvider(ValidConnectionString, options => options.UserNameClaimType = " ");
+
+ Assert.Throws(
+ () => provider.GetRequiredService().Validate());
+ }
+
+ [Fact]
+ public void StartupValidation_ValidConfiguration_Passes()
+ {
+ using var provider = BuildProvider(ValidConnectionString);
+
+ var exception = Record.Exception(() => provider.GetRequiredService().Validate());
+
+ Assert.Null(exception);
+ }
+
+ private static readonly Dictionary ValidConnectionString = new()
+ {
+ ["ConnectionStrings:SecurityDb"] = "Server=localhost;Database=Security;",
+ };
+
+ private static ServiceProvider BuildProvider(
+ Dictionary settings,
+ Action? configure = null)
+ {
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
+ return new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddAuthorizationClaims(configure)
+ .BuildServiceProvider();
+ }
+}