Skip to content

Compose multiple IClaimsTransformation registrations sequentially#65585

Open
kubaflo wants to merge 2 commits intodotnet:mainfrom
kubaflo:fix/claims-transformation-compose-46120
Open

Compose multiple IClaimsTransformation registrations sequentially#65585
kubaflo wants to merge 2 commits intodotnet:mainfrom
kubaflo:fix/claims-transformation-compose-46120

Conversation

@kubaflo
Copy link

@kubaflo kubaflo commented Mar 1, 2026

Summary

Fixes #46120 — IClaimsTransformation do not automagically compose

Problem

When multiple IClaimsTransformation implementations are registered in DI, only the last one is resolved and executed. This is because AuthenticationService takes a single IClaimsTransformation from the container, and DI resolves only the last registration for single-service resolution.

Root Cause

Standard DI resolves IClaimsTransformation as a single service — only the last registration wins. The AuthenticationServiceImpl constructor takes IClaimsTransformation directly.

Fix

  • Created an internal CompositeClaimsTransformation class that implements IClaimsTransformation and iterates all registered transformations sequentially in registration order
  • Modified AuthenticationServiceImpl to accept IEnumerable<IClaimsTransformation> (leveraging DI's built-in enumerable resolution) and wrap them in the composite before passing to the base AuthenticationService constructor
  • Zero public API changes — the composite is internal, and the base AuthenticationService class is unchanged

Changes

File Change
src/Http/Authentication.Core/src/CompositeClaimsTransformation.cs NEW — internal composite that iterates all transforms
src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs Changed constructor to accept IEnumerable<IClaimsTransformation>
src/Http/Authentication.Core/test/AuthenticationServiceTests.cs 3 new tests for composition behavior

Multi-Model Exploration

5 alternative approaches were explored:

# Model Approach Result
0 claude-opus-4.6-fast IEnumerable constructor overload on AuthenticationService ✅ Pass (public API change)
1 claude-sonnet-4.6 Internal CompositeClaimsTransformation (selected) ✅ Pass
2 gpt-5.2 DI factory registration ✅ Pass
3 gpt-5.3-codex Override AuthenticateAsync in impl ✅ Pass
4 gemini-3-pro-preview ActivatorUtilities factory ✅ Pass

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 claims
  • MultipleClaimsTransformationsRunInRegistrationOrder — verifies sequential execution order
  • SingleClaimsTransformationStillWorks — backward compatibility

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>
Copilot AI review requested due to automatic review settings March 1, 2026 12:29
@kubaflo
Copy link
Author

kubaflo commented Mar 1, 2026

🤖 AI Summary

📊 Expand Full Review
🔍 Pre-Flight — Context & Validation

Issue: #46120 - IClaimsTransformation do not automagically compose
Area: area-auth (src/Http/Authentication.Core/)
PR: None — will create

Key Findings

  • AuthenticationService takes a single IClaimsTransformation via DI constructor injection
  • DI registers NoopClaimsTransformation as default singleton via TryAddSingleton
  • When multiple IClaimsTransformation registrations exist, DI only resolves the LAST one registered
  • The issue requests a composite pattern: iterate all registered transformations sequentially
  • David Fowl (team member) said "I think this is a reasonable change to make" — confirmed by 24 thumbs up
  • HaoK noted it's technically a breaking change (new behavior when multiple registrations exist)
  • Current workaround: users must manually create their own composite implementation

Root Cause

Standard DI resolves IClaimsTransformation as a single service. Only IEnumerable<IClaimsTransformation> would resolve all. The AuthenticationService constructor takes a single IClaimsTransformation, so only the last registration wins.

Fix Approach

  1. Change DI registration to register NoopClaimsTransformation as itself (not as IClaimsTransformation)
  2. Create a new CompositeClaimsTransformation class that:
    • Takes IEnumerable<IClaimsTransformation> in constructor
    • Iterates all transformations sequentially in TransformAsync
  3. Register CompositeClaimsTransformation as IClaimsTransformation (scoped, to support DbContext-using transformations)
  4. Users register their transformations via services.AddTransient<IClaimsTransformation, MyTransform>()

Test Command

dotnet test src/Http/Authentication.Core/test/Microsoft.AspNetCore.Authentication.Core.Test.csproj

Fix Candidates

# Source Approach Files Changed Notes
1 Issue author ReducerClaimsTransformation with foreach loop AuthenticationCoreServiceCollectionExtensions.cs, new class Simplest
2 FAMEEXE comment Similar composite approach Unknown Not reviewed

🧪 Test — Bug Reproduction

Test Result: ✅ TESTS CREATED

Test Command: dotnet test src/Http/Authentication.Core/test/Microsoft.AspNetCore.Authentication.Core.Test.csproj --filter "MultipleClaimsTransformations|SingleClaimsTransformationStillWorks"
Tests Created: src/Http/Authentication.Core/test/AuthenticationServiceTests.cs

Tests Written

  • MultipleClaimsTransformationsAreComposed — Registers 2 IClaimsTransformation, verifies both run and add claims
  • MultipleClaimsTransformationsRunInRegistrationOrder — Registers 3, verifies execution order matches registration order
  • SingleClaimsTransformationStillWorks — Backward compat: single registration still works

Conclusion

Tests reproduce the bug: multiple registrations only run the last one (DI resolves single service).


🚦 Gate — Test Verification & Regression

Gate Result: ✅ PASSED

Test Command: dotnet test src/Http/Authentication.Core/test/Microsoft.AspNetCore.Authentication.Core.Test.csproj --filter "MultipleClaimsTransformations|SingleClaimsTransformationStillWorks"

New Tests vs Buggy Code

  • MultipleClaimsTransformationsAreComposed: FAIL as expected ✅
  • MultipleClaimsTransformationsRunInRegistrationOrder: FAIL as expected ✅
  • SingleClaimsTransformationStillWorks: PASS ✅

Regression Check

  • Total tests run: 3 (focused filter)
  • Pre-existing failures: 0
  • New failures introduced: 0 (the 2 failures are expected — they prove the bug)

Conclusion

Tests are properly written and correctly detect the missing composite behavior.


🔧 Fix — Analysis & Comparison (✅ 4 passed, ❌ 1 failed)

Fix Exploration Summary

Total Attempts: 5
Passing Candidates: 5
Selected Fix: Attempt 1 — Internal CompositeClaimsTransformation + AuthenticationServiceImpl modification

Attempt Results

# Model Approach Result Key Insight
0 claude-opus-4.6-fast IEnumerable constructor overload on AuthenticationService ✅ Pass Works but adds public API surface
1 claude-sonnet-4.6 Internal CompositeClaimsTransformation, AuthenticationServiceImpl takes IEnumerable ✅ Pass No public API changes — cleanest
2 gpt-5.2 DI registration factory in AddAuthenticationCore ✅ Pass More complex DI changes
3 gpt-5.3-codex Override AuthenticateAsync in AuthenticationServiceImpl ✅ Pass Duplicates logic from base
4 gemini-3-pro-preview Factory with ActivatorUtilities.CreateInstance ✅ Pass More complex, same result

Cross-Pollination

All 5 approaches converge on the same insight: compose IEnumerable into a single IClaimsTransformation. The variation is WHERE the composition happens.

Exhausted: Yes

Comparison

Criterion Attempt 0 Attempt 1 Attempt 2 Attempt 3 Attempt 4
Correctness
No Public API Change
Simplicity Medium High Low Medium Low
Backward Compat

Recommendation

Attempt 1 is the best: internal CompositeClaimsTransformation wraps all registered IClaimsTransformation instances, AuthenticationServiceImpl takes IEnumerable<IClaimsTransformation> and passes the composite to the base AuthenticationService constructor. Zero public API changes, minimal code, leverages existing DI IEnumerable<T> resolution.

Attempt 0: PASS

Attempt 0 — Baseline (claude-opus-4.6-fast)

Approach: Add IEnumerable constructor overload to AuthenticationService

Changes:

  1. AuthenticationService.cs — Added new constructor accepting IEnumerable<IClaimsTransformation>, modified AuthenticateAsync to iterate all transforms when enumerable is present
  2. AuthenticationServiceImpl.cs — Changed to use IEnumerable<IClaimsTransformation> constructor
  3. PublicAPI.Unshipped.txt — Added new constructor to public API surface

Key Design Decisions:

  • Kept old single-IClaimsTransformation constructor for backward compatibility
  • New constructor stores _transforms enumerable, iterates sequentially in AuthenticateAsync
  • AuthenticationServiceImpl (the default DI-registered impl) uses the new constructor
  • No changes to DI registration — users register via services.AddTransient<IClaimsTransformation, MyTransform>()
  • Existing TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation> kept for backward compat
📄 Diff
diff --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: PASS

Approach: CompositeClaimsTransformation in AuthenticationServiceImpl

Strategy

Create an internal CompositeClaimsTransformation class that sequences all registered IClaimsTransformation instances, then modify AuthenticationServiceImpl (not the public AuthenticationService) to accept IEnumerable<IClaimsTransformation> and compose them into a single composite before passing to the base class.

Key Differences from Attempt 0

  • No changes to public AuthenticationService — the public API (Transform property, constructor signature) remains unchanged
  • No new public API surfaceCompositeClaimsTransformation is internal
  • Composition happens at the internal impl levelAuthenticationServiceImpl now takes IEnumerable<IClaimsTransformation> and wraps them in CompositeClaimsTransformation
  • No DI registration changes — the TryAddSingleton<IClaimsTransformation, NoopClaimsTransformation> in AuthenticationCoreServiceCollectionExtensions stays as-is; DI's built-in IEnumerable<T> resolution collects all registrations

Files Changed

  1. src/Http/Authentication.Core/src/CompositeClaimsTransformation.cs — NEW internal class
  2. src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs — constructor changed from IClaimsTransformation to IEnumerable<IClaimsTransformation>
  3. src/Http/Authentication.Core/test/AuthenticationServiceTests.cs — added 3 new tests

How It Works

  • When DI resolves AuthenticationServiceImpl, it injects IEnumerable<IClaimsTransformation> which includes all registered IClaimsTransformation services
  • CompositeClaimsTransformation.TransformAsync iterates them sequentially
  • The first registration (NoopClaimsTransformation, if not overridden) is harmless as it returns the principal unchanged
  • Users can add multiple IClaimsTransformation registrations and all will run in registration order
📄 Diff
diff --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: PASS

Attempt 2: Compose claims transformations via IAuthenticationService factory

Idea

Avoid changing AuthenticationService/AuthenticationServiceImpl constructors by changing DI registration: register IAuthenticationService with a scoped factory that resolves all IClaimsTransformation instances (GetServices<IClaimsTransformation>()) and wraps them into a composite that runs sequentially.

Why this is different from prior attempts

  • No public API changes (no new constructor overloads on AuthenticationService).
  • No change to AuthenticationServiceImpl constructor/signature; composition happens only in the DI setup.

Expected behavior

  • If multiple IClaimsTransformation are registered, they run in registration order.
  • Single transformation continues to work.
📄 Diff
diff --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: UNKNOWN

Approach: override AuthenticationServiceImpl.AuthenticateAsync to apply all registered IClaimsTransformation instances in sequence.

Why this is different:

  • Does not add a public API constructor overload on AuthenticationService.
  • Does not introduce a CompositeClaimsTransformation type.
  • Does not change AddAuthenticationCore registration to compose transforms.

Implementation details:

  • Inject IEnumerable into AuthenticationServiceImpl.
  • Re-implement AuthenticateAsync flow in AuthenticationServiceImpl and preserve existing behavior/messages.
  • On success, iterate transformations in DI registration order and apply each transform sequentially.
  • Keep a local transform cache to preserve run-once semantics per principal instance.
📄 Diff
diff --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: UNKNOWN

Implement a fix by registering AuthenticationServiceImpl via a factory that uses ActivatorUtilities to inject a CompositeClaimsTransformation. The CompositeClaimsTransformation wraps all registered IClaimsTransformation services.

@github-actions github-actions bot added the needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically label Mar 1, 2026
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Mar 1, 2026
@dotnet-policy-service
Copy link
Contributor

Thanks for your PR, @@kubaflo. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CompositeClaimsTransformation that executes all IClaimsTransformations in sequence.
  • Update AuthenticationServiceImpl to receive IEnumerable<IClaimsTransformation> and pass a composite to the base AuthenticationService.
  • 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)
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
: AuthenticationService(schemes, handlers, new CompositeClaimsTransformation(transforms), options)
: AuthenticationService(schemes, handlers, transforms, options)

Copilot uses AI. Check for mistakes.
Comment on lines 490 to 492
var identity = principal.Identity as ClaimsIdentity ?? new ClaimsIdentity();
identity.AddClaim(new Claim(_type, _value));
return Task.FromResult(principal);
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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);

Copilot uses AI. Check for mistakes.
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Indicates that the PR has been added by a community member needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IClaimsTransformation do not automagically compose

2 participants