Compose multiple IClaimsTransformation registrations sequentially#65585
Compose multiple IClaimsTransformation registrations sequentially#65585kubaflo wants to merge 2 commits intodotnet:mainfrom
Conversation
When multiple IClaimsTransformation implementations are registered in DI, they now all execute sequentially in registration order during authentication. Previously, only the last registered transformation was used. Introduces an internal CompositeClaimsTransformation that wraps all registered IClaimsTransformation instances and iterates them in AuthenticateAsync. Fixes dotnet#46120 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🤖 AI Summary📊 Expand Full Review🔍 Pre-Flight — Context & ValidationIssue: #46120 - IClaimsTransformation do not automagically compose Key Findings
Root CauseStandard DI resolves Fix Approach
Test Command
Fix Candidates
🧪 Test — Bug ReproductionTest Result: ✅ TESTS CREATEDTest Command: Tests Written
ConclusionTests reproduce the bug: multiple registrations only run the last one (DI resolves single service). 🚦 Gate — Test Verification & RegressionGate Result: ✅ PASSEDTest Command: New Tests vs Buggy Code
Regression Check
ConclusionTests are properly written and correctly detect the missing composite behavior. 🔧 Fix — Analysis & Comparison (✅ 4 passed, ❌ 1 failed)Fix Exploration SummaryTotal Attempts: 5 Attempt Results
Cross-PollinationAll 5 approaches converge on the same insight: compose IEnumerable into a single IClaimsTransformation. The variation is WHERE the composition happens. Exhausted: Yes Comparison
RecommendationAttempt 1 is the best: internal ✅ Attempt 0: PASSAttempt 0 — Baseline (claude-opus-4.6-fast)Approach: Add IEnumerable constructor overload to AuthenticationService Changes:
Key Design Decisions:
📄 Diffdiff --git a/src/Http/Authentication.Core/src/AuthenticationService.cs b/src/Http/Authentication.Core/src/AuthenticationService.cs
index 3b45ffc56d..e2c95e2fa4 100644
--- a/src/Http/Authentication.Core/src/AuthenticationService.cs
+++ b/src/Http/Authentication.Core/src/AuthenticationService.cs
@@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Authentication;
public class AuthenticationService : IAuthenticationService
{
private HashSet<ClaimsPrincipal>? _transformCache;
+ private readonly IEnumerable<IClaimsTransformation>? _transforms;
/// <summary>
/// Constructor.
@@ -34,6 +35,27 @@ public class AuthenticationService : IAuthenticationService
Options = options.Value;
}
+ /// <summary>
+ /// Constructor that accepts multiple <see cref="IClaimsTransformation"/> instances.
+ /// All registered transformations are executed sequentially in registration order.
+ /// </summary>
+ /// <param name="schemes">The <see cref="IAuthenticationSchemeProvider"/>.</param>
+ /// <param name="handlers">The <see cref="IAuthenticationHandlerProvider"/>.</param>
+ /// <param name="transforms">The <see cref="IClaimsTransformation"/> instances to compose.</param>
+ /// <param name="options">The <see cref="AuthenticationOptions"/>.</param>
+ public AuthenticationService(
+ IAuthenticationSchemeProvider schemes,
+ IAuthenticationHandlerProvider handlers,
+ IEnumerable<IClaimsTransformation> transforms,
+ IOptions<AuthenticationOptions> options)
+ {
+ Schemes = schemes;
+ Handlers = handlers;
+ _transforms = transforms;
+ Transform = new NoopClaimsTransformation();
+ Options = options.Value;
+ }
+
/// <summary>
/// Used to lookup AuthenticationSchemes.
/// </summary>
@@ -89,7 +111,17 @@ public class AuthenticationService : IAuthenticationService
if (doTransform)
{
- principal = await Transform.TransformAsync(principal);
+ if (_transforms is not null)
+ {
+ foreach (var t in _transforms)
+ {
+ principal = await t.TransformAsync(principal);
+ }
+ }
+ else
+ {
+ principal = await Transform.TransformAsync(principal);
+ }
_transformCache.Add(principal);
}
return AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, result.Ticket!.AuthenticationScheme));
diff --git a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
index 8aca027a1a..a7ffc137c0 100644
--- a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
+++ b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
@@ -11,10 +11,10 @@ namespace Microsoft.AspNetCore.Authentication;
internal sealed class AuthenticationServiceImpl(
IAuthenticationSchemeProvider schemes,
IAuthenticationHandlerProvider handlers,
- IClaimsTransformation transform,
+ IEnumerable<IClaimsTransformation> transforms,
IOptions<AuthenticationOptions> options,
AuthenticationMetrics metrics)
- : AuthenticationService(schemes, handlers, transform, options)
+ : AuthenticationService(schemes, handlers, transforms, options)
{
public override async Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
{
diff --git a/src/Http/Authentication.Core/src/PublicAPI.Unshipped.txt b/src/Http/Authentication.Core/src/PublicAPI.Unshipped.txt
index 7dc5c58110..21c71f5bbe 100644
--- a/src/Http/Authentication.Core/src/PublicAPI.Unshipped.txt
+++ b/src/Http/Authentication.Core/src/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
#nullable enable
+Microsoft.AspNetCore.Authentication.AuthenticationService.AuthenticationService(Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider! schemes, Microsoft.AspNetCore.Authentication.IAuthenticationHandlerProvider! handlers, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Authentication.IClaimsTransformation!>! transforms, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Authentication.AuthenticationOptions!>! options) -> void
diff --git a/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs b/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
index 5a5df2280e..a536e2d657 100644
--- a/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
+++ b/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
@@ -48,6 +48,66 @@ public class AuthenticationServiceTests
Assert.Equal(3, transform.Ran);
}
+ [Fact]
+ public async Task MultipleClaimsTransformationsAreComposed()
+ {
+ var transform1 = new ClaimsAdder("role", "admin");
+ var transform2 = new ClaimsAdder("role", "user");
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddTransient<IClaimsTransformation>(_ => transform1)
+ .AddTransient<IClaimsTransformation>(_ => transform2)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ var result = await context.AuthenticateAsync("base");
+ Assert.True(result.Succeeded);
+ Assert.Contains(result.Principal!.Claims, c => c.Type == "role" && c.Value == "admin");
+ Assert.Contains(result.Principal!.Claims, c => c.Type == "role" && c.Value == "user");
+ }
+
+ [Fact]
+ public async Task MultipleClaimsTransformationsRunInRegistrationOrder()
+ {
+ var order = new List<int>();
+ var transform1 = new OrderTracker(order, 1);
+ var transform2 = new OrderTracker(order, 2);
+ var transform3 = new OrderTracker(order, 3);
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddTransient<IClaimsTransformation>(_ => transform1)
+ .AddTransient<IClaimsTransformation>(_ => transform2)
+ .AddTransient<IClaimsTransformation>(_ => transform3)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ await context.AuthenticateAsync("base");
+ Assert.Equal(new[] { 1, 2, 3 }, order);
+ }
+
+ [Fact]
+ public async Task SingleClaimsTransformationStillWorks()
+ {
+ var transform = new RunOnce();
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddTransient<IClaimsTransformation>(_ => transform)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ await context.AuthenticateAsync("base");
+ Assert.Equal(1, transform.Ran);
+ }
+
[Fact]
public async Task ChallengeThrowsForSchemeMismatch()
{
@@ -414,4 +474,41 @@ public class AuthenticationServiceTests
}
}
+ private class ClaimsAdder : IClaimsTransformation
+ {
+ private readonly string _type;
+ private readonly string _value;
+
+ public ClaimsAdder(string type, string value)
+ {
+ _type = type;
+ _value = value;
+ }
+
+ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
+ {
+ var identity = principal.Identity as ClaimsIdentity ?? new ClaimsIdentity();
+ identity.AddClaim(new Claim(_type, _value));
+ return Task.FromResult(principal);
+ }
+ }
+
+ private class OrderTracker : IClaimsTransformation
+ {
+ private readonly List<int> _order;
+ private readonly int _id;
+
+ public OrderTracker(List<int> order, int id)
+ {
+ _order = order;
+ _id = id;
+ }
+
+ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
+ {
+ _order.Add(_id);
+ return Task.FromResult(principal);
+ }
+ }
+
}✅ Attempt 1: PASSApproach: CompositeClaimsTransformation in AuthenticationServiceImplStrategyCreate an internal Key Differences from Attempt 0
Files Changed
How It Works
📄 Diffdiff --git a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
index 8aca027a1a..6ec0b061d3 100644
--- a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
+++ b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
@@ -11,10 +11,10 @@ namespace Microsoft.AspNetCore.Authentication;
internal sealed class AuthenticationServiceImpl(
IAuthenticationSchemeProvider schemes,
IAuthenticationHandlerProvider handlers,
- IClaimsTransformation transform,
+ IEnumerable<IClaimsTransformation> transforms,
IOptions<AuthenticationOptions> options,
AuthenticationMetrics metrics)
- : AuthenticationService(schemes, handlers, transform, options)
+ : AuthenticationService(schemes, handlers, new CompositeClaimsTransformation(transforms), options)
{
public override async Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
{
diff --git a/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs b/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
index 5a5df2280e..2dfbb7b23f 100644
--- a/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
+++ b/src/Http/Authentication.Core/test/AuthenticationServiceTests.cs
@@ -240,6 +240,86 @@ public class AuthenticationServiceTests
await context.ForbidAsync();
}
+ [Fact]
+ public async Task SingleClaimsTransformationStillWorks()
+ {
+ var transform = new TrackingTransformation("A");
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddSingleton<IClaimsTransformation>(transform)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ await context.AuthenticateAsync("base");
+ Assert.Equal(["A"], transform.Order);
+ }
+
+ [Fact]
+ public async Task MultipleClaimsTransformationsAreComposed()
+ {
+ var transform1 = new TrackingTransformation("First");
+ var transform2 = new TrackingTransformation("Second");
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddSingleton<IClaimsTransformation>(transform1)
+ .AddSingleton<IClaimsTransformation>(transform2)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ await context.AuthenticateAsync("base");
+ Assert.Equal(1, transform1.RunCount);
+ Assert.Equal(1, transform2.RunCount);
+ }
+
+ [Fact]
+ public async Task MultipleClaimsTransformationsRunInRegistrationOrder()
+ {
+ var executionOrder = new List<string>();
+ var transform1 = new OrderCapturingTransformation("First", executionOrder);
+ var transform2 = new OrderCapturingTransformation("Second", executionOrder);
+ var services = new ServiceCollection().AddOptions().AddAuthenticationCore(o =>
+ {
+ o.AddScheme<BaseHandler>("base", "whatever");
+ })
+ .AddSingleton<IClaimsTransformation>(transform1)
+ .AddSingleton<IClaimsTransformation>(transform2)
+ .BuildServiceProvider();
+ var context = new DefaultHttpContext();
+ context.RequestServices = services;
+
+ await context.AuthenticateAsync("base");
+ Assert.Equal("First", executionOrder[executionOrder.Count - 2]);
+ Assert.Equal("Second", executionOrder[executionOrder.Count - 1]);
+ }
+
+ private class TrackingTransformation(string name) : IClaimsTransformation
+ {
+ public int RunCount { get; private set; }
+ public List<string> Order { get; } = [];
+
+ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
+ {
+ RunCount++;
+ Order.Add(name);
+ return Task.FromResult(new ClaimsPrincipal());
+ }
+ }
+
+ private class OrderCapturingTransformation(string name, List<string> order) : IClaimsTransformation
+ {
+ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
+ {
+ order.Add(name);
+ return Task.FromResult(new ClaimsPrincipal());
+ }
+ }
+
private class RunOnce : IClaimsTransformation
{
public int Ran = 0;✅ Attempt 2: PASSAttempt 2: Compose claims transformations via IAuthenticationService factoryIdeaAvoid changing Why this is different from prior attempts
Expected behavior
📄 Diffdiff --git a/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs b/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs
index be04d14e37..122a5b660c 100644
--- a/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs
+++ b/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs
@@ -1,8 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Linq;
+using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
namespace Microsoft.Extensions.DependencyInjection;
@@ -21,7 +24,25 @@ public static class AuthenticationCoreServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
services.AddMetrics();
- services.TryAddScoped<IAuthenticationService, AuthenticationServiceImpl>();
+ services.TryAddScoped<IAuthenticationService>(sp =>
+ {
+ var schemes = sp.GetRequiredService<IAuthenticationSchemeProvider>();
+ var handlers = sp.GetRequiredService<IAuthenticationHandlerProvider>();
+ var transforms = sp.GetServices<IClaimsTransformation>().ToArray();
+ var transform = transforms.Length switch
+ {
+ 0 => new NoopClaimsTransformation(),
+ 1 => transforms[0],
+ _ => new CompositeClaimsTransformation(transforms),
+ };
+
+ return new AuthenticationServiceImpl(
+ schemes,
+ handlers,
+ transform,
+ sp.GetRequiredService<IOptions<AuthenticationOptions>>(),
+ sp.GetRequiredService<AuthenticationMetrics>());
+ });
services.TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation>(); // Can be replaced with scoped ones that use DbContext
services.TryAddScoped<IAuthenticationHandlerProvider, AuthenticationHandlerProvider>();
services.TryAddSingleton<IAuthenticationSchemeProvider, AuthenticationSchemeProvider>();
@@ -44,4 +65,25 @@ public static class AuthenticationCoreServiceCollectionExtensions
services.Configure(configureOptions);
return services;
}
+
+ private sealed class CompositeClaimsTransformation(IClaimsTransformation[] transforms) : IClaimsTransformation
+ {
+ public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
+ {
+ ArgumentNullException.ThrowIfNull(principal);
+ return TransformCoreAsync(principal);
+ }
+
+ private async Task<ClaimsPrincipal> TransformCoreAsync(ClaimsPrincipal principal)
+ {
+ var current = principal;
+
+ foreach (var transform in transforms)
+ {
+ current = await transform.TransformAsync(current);
+ }
+
+ return current;
+ }
+ }
}⚪ Attempt 3: UNKNOWNApproach: override AuthenticationServiceImpl.AuthenticateAsync to apply all registered IClaimsTransformation instances in sequence. Why this is different:
Implementation details:
📄 Diffdiff --git a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
index 8aca027a1a..057d0ab908 100644
--- a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
+++ b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
+using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
@@ -12,17 +13,54 @@ internal sealed class AuthenticationServiceImpl(
IAuthenticationSchemeProvider schemes,
IAuthenticationHandlerProvider handlers,
IClaimsTransformation transform,
+ IEnumerable<IClaimsTransformation> transformations,
IOptions<AuthenticationOptions> options,
AuthenticationMetrics metrics)
: AuthenticationService(schemes, handlers, transform, options)
{
+ private HashSet<ClaimsPrincipal>? _transformCache;
+
public override async Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
{
AuthenticateResult result;
var startTimestamp = Stopwatch.GetTimestamp();
try
{
- result = await base.AuthenticateAsync(context, scheme);
+ if (scheme is null)
+ {
+ var defaultScheme = await Schemes.GetDefaultAuthenticateSchemeAsync();
+ scheme = defaultScheme?.Name;
+ if (scheme is null)
+ {
+ throw new InvalidOperationException($"No authenticationScheme was specified, and there was no DefaultAuthenticateScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).");
+ }
+ }
+
+ var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme);
+
+ result = (await handler.AuthenticateAsync()) ?? AuthenticateResult.NoResult();
+ if (result.Succeeded)
+ {
+ var principal = result.Principal!;
+ var doTransform = true;
+ _transformCache ??= [];
+ if (_transformCache.Contains(principal))
+ {
+ doTransform = false;
+ }
+
+ if (doTransform)
+ {
+ foreach (var claimsTransformation in transformations)
+ {
+ principal = await claimsTransformation.TransformAsync(principal);
+ }
+
+ _transformCache.Add(principal);
+ }
+
+ result = AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, result.Ticket!.AuthenticationScheme));
+ }
}
catch (Exception ex)
{
@@ -93,4 +131,20 @@ internal sealed class AuthenticationServiceImpl(
metrics.SignOutCompleted(scheme, exception: null);
}
+
+ private async Task<Exception> CreateMissingHandlerException(string scheme)
+ {
+ var schemes = string.Join(", ", (await Schemes.GetAllSchemesAsync()).Select(sch => sch.Name));
+
+ var footer = $" Did you forget to call AddAuthentication().Add[SomeAuthHandler](\"{scheme}\",...)?";
+
+ if (string.IsNullOrEmpty(schemes))
+ {
+ return new InvalidOperationException(
+ $"No authentication handlers are registered." + footer);
+ }
+
+ return new InvalidOperationException(
+ $"No authentication handler is registered for the scheme '{scheme}'. The registered schemes are: {schemes}." + footer);
+ }
}⚪ Attempt 4: UNKNOWNImplement a fix by registering AuthenticationServiceImpl via a factory that uses ActivatorUtilities to inject a CompositeClaimsTransformation. The CompositeClaimsTransformation wraps all registered IClaimsTransformation services. |
|
Thanks for your PR, @@kubaflo. Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
There was a problem hiding this comment.
Pull request overview
This PR fixes the long-standing DI behavior where registering multiple IClaimsTransformation implementations results in only the last registration being used, by composing all registered transformations and running them sequentially.
Changes:
- Add an internal
CompositeClaimsTransformationthat executes allIClaimsTransformations in sequence. - Update
AuthenticationServiceImplto receiveIEnumerable<IClaimsTransformation>and pass a composite to the baseAuthenticationService. - Add tests validating multi-transform composition and registration order.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Http/Authentication.Core/src/CompositeClaimsTransformation.cs | Introduces an internal composite implementation to execute multiple transformations sequentially. |
| src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs | Switches DI constructor dependency from single transform to IEnumerable and wraps with the composite. |
| src/Http/Authentication.Core/test/AuthenticationServiceTests.cs | Adds coverage for composition, ordering, and single-transform compatibility. |
| IOptions<AuthenticationOptions> options, | ||
| AuthenticationMetrics metrics) | ||
| : AuthenticationService(schemes, handlers, transform, options) | ||
| : AuthenticationService(schemes, handlers, new CompositeClaimsTransformation(transforms), options) |
There was a problem hiding this comment.
AuthenticationServiceImpl now expects IEnumerable<IClaimsTransformation>. There are call sites that instantiate AuthenticationServiceImpl directly (e.g., src/Security/Authentication/test/AuthenticationMetricsTest.cs) that will no longer compile until they are updated to pass an enumerable (or wrapped appropriately).
| : AuthenticationService(schemes, handlers, new CompositeClaimsTransformation(transforms), options) | |
| : AuthenticationService(schemes, handlers, transforms, options) |
| var identity = principal.Identity as ClaimsIdentity ?? new ClaimsIdentity(); | ||
| identity.AddClaim(new Claim(_type, _value)); | ||
| return Task.FromResult(principal); |
There was a problem hiding this comment.
In ClaimsAdder.TransformAsync, when principal.Identity is not a ClaimsIdentity, a new ClaimsIdentity is created but never attached to the ClaimsPrincipal. That means the added claim can be silently lost in that case, making the helper (and the test) less robust. Consider adding the new identity to the principal (or ensuring you always mutate an identity that is actually part of the principal).
| var identity = principal.Identity as ClaimsIdentity ?? new ClaimsIdentity(); | |
| identity.AddClaim(new Claim(_type, _value)); | |
| return Task.FromResult(principal); | |
| if (principal.Identity is ClaimsIdentity claimsIdentity) | |
| { | |
| claimsIdentity.AddClaim(new Claim(_type, _value)); | |
| return Task.FromResult(principal); | |
| } | |
| var newIdentity = new ClaimsIdentity(); | |
| newIdentity.AddClaim(new Claim(_type, _value)); | |
| var newPrincipal = new ClaimsPrincipal(principal); | |
| newPrincipal.AddIdentity(newIdentity); | |
| return Task.FromResult(newPrincipal); |
- ClaimsAdder: attach new ClaimsIdentity to principal when Identity is not a ClaimsIdentity (prevents silently lost claims) - AuthenticationMetricsTest: wrap single IClaimsTransformation mock in array to match new IEnumerable<IClaimsTransformation> constructor parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Fixes #46120 — IClaimsTransformation do not automagically compose
Problem
When multiple
IClaimsTransformationimplementations are registered in DI, only the last one is resolved and executed. This is becauseAuthenticationServicetakes a singleIClaimsTransformationfrom the container, and DI resolves only the last registration for single-service resolution.Root Cause
Standard DI resolves
IClaimsTransformationas a single service — only the last registration wins. TheAuthenticationServiceImplconstructor takesIClaimsTransformationdirectly.Fix
CompositeClaimsTransformationclass that implementsIClaimsTransformationand iterates all registered transformations sequentially in registration orderAuthenticationServiceImplto acceptIEnumerable<IClaimsTransformation>(leveraging DI's built-in enumerable resolution) and wrap them in the composite before passing to the baseAuthenticationServiceconstructorAuthenticationServiceclass is unchangedChanges
src/Http/Authentication.Core/src/CompositeClaimsTransformation.cssrc/Http/Authentication.Core/src/AuthenticationServiceImpl.csIEnumerable<IClaimsTransformation>src/Http/Authentication.Core/test/AuthenticationServiceTests.csMulti-Model Exploration
5 alternative approaches were explored:
Selected Attempt 1 for: zero public API changes, minimal code, clean composition.
Test Results
All 59 tests pass (56 existing + 3 new):
MultipleClaimsTransformationsAreComposed— verifies both transforms run and add claimsMultipleClaimsTransformationsRunInRegistrationOrder— verifies sequential execution orderSingleClaimsTransformationStillWorks— backward compatibility