From 9d55d404930bda5dc2d677a9c167564b8a623b3e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 09:50:23 -0400 Subject: [PATCH] feat: Add UserIdentityScopedSessionStore Add support for using the ASP.Net Core ambient `ClaimsIdentity` User, along with a user-specified claim type to scope the session store based on authenticated identity. Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 1 + ...rosoft.Agents.AI.Hosting.AspNetCore.csproj | 30 ++ .../UserIdentityScopedSessionStore.cs | 76 ++++ .../DelegatingAgentSessionStoreTests.cs | 24 +- ...crosoft.Agents.AI.Hosting.UnitTests.csproj | 1 + .../UserIdentityScopedSessionStoreTests.cs | 393 ++++++++++++++++++ 6 files changed, 513 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 87e6d9d3c6..d9c0ec80d4 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -590,6 +590,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj new file mode 100644 index 0000000000..2dd1834008 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/Microsoft.Agents.AI.Hosting.AspNetCore.csproj @@ -0,0 +1,30 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.AspNetCore + preview + $(NoWarn) + + + + + + true + true + true + + + + + + + + + + + + Microsoft Agent Framework Hosting ASP.NET Core + Provides Microsoft Agent Framework support for hosting agents in an ASP.NET Core context. + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs new file mode 100644 index 0000000000..b1eb6cbf57 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// A delegating that scopes session keys by a claim value +/// extracted from the current user's identity, ensuring that sessions are isolated per user. +/// The current user is extracted from the ambient ASP.NET . +/// +/// +/// This relies on , which uses +/// to provide access to the current . +/// +public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore +{ + private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly string _claimType; + private readonly bool _strict; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying to delegate to. + /// + /// The used to retrieve the current user's claims. + /// + /// + /// The claim type to extract from the user's identity for scoping. Defaults to . + /// + /// + /// If , an exception is thrown when the specified claim is not found. + /// If , operations proceed without scoping when the claim is absent. + /// + public UserIdentityScopedSessionStore(AgentSessionStore innerStore, + IHttpContextAccessor? contextAccessor, + string claimType = ClaimsIdentity.DefaultNameClaimType, + bool strict = true) : base(innerStore) + { + this._httpContextAccessor = contextAccessor; + this._claimType = claimType; + this._strict = strict; + } + + private string? GetScopeFromIdentity() + { + Claim? claim = this._httpContextAccessor? + .HttpContext? + .User?.Claims.FirstOrDefault(c => c.Type == this._claimType); + + if (this._strict && claim == null) + { + throw new InvalidOperationException($"No claim of type '{this._claimType}' found in principal."); + } + + return claim?.Value; + } + + private string? ScopeId => this.GetScopeFromIdentity(); + + private string GetScopedConversationId(string bareConversationId) => $"{this.ScopeId}:{bareConversationId}"; + + /// + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, this.GetScopedConversationId(conversationId), cancellationToken); + + /// + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, this.GetScopedConversationId(conversationId), session, cancellationToken); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs index 5a55b6e67b..f73776f172 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -72,20 +72,20 @@ public class DelegatingAgentSessionStoreTests public async Task GetSessionAsyncDelegatesToInnerStoreAsync() { // Arrange - const string expectedConversationId = "test-conversation-id"; + const string ExpectedConversationId = "test-conversation-id"; var expectedCancellationToken = new CancellationToken(); this._innerStoreMock .Setup(x => x.GetSessionAsync( It.Is(a => a == this._agentMock.Object), - It.Is(c => c == expectedConversationId), + It.Is(c => c == ExpectedConversationId), It.Is(ct => ct == expectedCancellationToken))) .ReturnsAsync(this._testSession); // Act var session = await this._delegatingStore.GetSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedCancellationToken); // Assert @@ -93,7 +93,7 @@ public class DelegatingAgentSessionStoreTests this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedCancellationToken), Times.Once); } @@ -105,14 +105,14 @@ public class DelegatingAgentSessionStoreTests public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() { // Arrange - const string expectedConversationId = "test-conversation-id"; + const string ExpectedConversationId = "test-conversation-id"; var expectedCancellationToken = new CancellationToken(); var expectedSession = new TestAgentSession(); this._innerStoreMock .Setup(x => x.SaveSessionAsync( It.Is(a => a == this._agentMock.Object), - It.Is(c => c == expectedConversationId), + It.Is(c => c == ExpectedConversationId), It.Is(s => s == expectedSession), It.Is(ct => ct == expectedCancellationToken))) .Returns(ValueTask.CompletedTask); @@ -120,7 +120,7 @@ public class DelegatingAgentSessionStoreTests // Act await this._delegatingStore.SaveSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedSession, expectedCancellationToken); @@ -128,7 +128,7 @@ public class DelegatingAgentSessionStoreTests this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedSession, expectedCancellationToken), Times.Once); @@ -141,7 +141,7 @@ public class DelegatingAgentSessionStoreTests public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() { // Arrange - const string expectedConversationId = "test-conversation-id"; + const string ExpectedConversationId = "test-conversation-id"; var taskCompletionSource = new TaskCompletionSource(); var innerStoreMock = new Mock(); @@ -152,7 +152,7 @@ public class DelegatingAgentSessionStoreTests var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); // Act - var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, expectedConversationId); + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId); // Assert Assert.False(resultTask.IsCompleted); @@ -168,7 +168,7 @@ public class DelegatingAgentSessionStoreTests public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() { // Arrange - const string expectedConversationId = "test-conversation-id"; + const string ExpectedConversationId = "test-conversation-id"; var expectedSession = new TestAgentSession(); var taskCompletionSource = new TaskCompletionSource(); @@ -180,7 +180,7 @@ public class DelegatingAgentSessionStoreTests var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); // Act - var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, expectedConversationId, expectedSession); + var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession); // Assert Assert.False(resultTask.IsCompleted); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj index 1279b20397..a6e2ccdb38 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj @@ -6,6 +6,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs new file mode 100644 index 0000000000..545dd92915 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for the class. +/// +public class UserIdentityScopedSessionStoreTests +{ + private const string TestUserId = "test-user-id"; + private const string TestConversationId = "test-conversation-id"; + private const string CustomClaimType = "custom-claim-type"; + private const string CustomClaimValue = "custom-claim-value"; + private const string User1 = "user-1"; + private const string User2 = "user-2"; + + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly Mock _httpContextAccessorMock; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public UserIdentityScopedSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._httpContextAccessorMock = new Mock(); + this._testSession = new TestAgentSession(); + + this._innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(this._testSession); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() => + Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object)); + + /// + /// Verify that constructor accepts null IHttpContextAccessor. + /// + [Fact] + public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() + { + // Act & Assert - should not throw + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, strict: false); + Assert.NotNull(store); + } + + #endregion + + #region GetSessionAsync Tests + + /// + /// Verify that GetSessionAsync scopes the conversation ID with the user's claim value. + /// + [Fact] + public async Task GetSessionAsyncScopesConversationIdWithUserClaimAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"{TestUserId}:{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync uses custom claim type when specified. + /// + [Fact] + public async Task GetSessionAsyncUsesCustomClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + claimType: CustomClaimType); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"{CustomClaimValue}:{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync throws InvalidOperationException when claim is missing in strict mode. + /// + [Fact] + public async Task GetSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: true); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); + + Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message); + } + + /// + /// Verify that GetSessionAsync does not throw when claim is missing in non-strict mode. + /// + [Fact] + public async Task GetSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: false); + + // Act - should not throw + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - conversation ID should use null scope + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $":{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync returns the session from the inner store. + /// + [Fact] + public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + + // Act + var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + Assert.Same(this._testSession, result); + } + + #endregion + + #region SaveSessionAsync Tests + + /// + /// Verify that SaveSessionAsync scopes the conversation ID with the user's claim value. + /// + [Fact] + public async Task SaveSessionAsyncScopesConversationIdWithUserClaimAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var sessionToSave = new TestAgentSession(); + + // Act + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + $"{TestUserId}:{TestConversationId}", + sessionToSave, + It.IsAny()), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync uses custom claim type when specified. + /// + [Fact] + public async Task SaveSessionAsyncUsesCustomClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + claimType: CustomClaimType); + var sessionToSave = new TestAgentSession(); + + // Act + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + $"{CustomClaimValue}:{TestConversationId}", + sessionToSave, + It.IsAny()), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync throws InvalidOperationException when claim is missing in strict mode. + /// + [Fact] + public async Task SaveSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: true); + var sessionToSave = new TestAgentSession(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); + + Assert.Contains(ClaimsIdentity.DefaultNameClaimType, exception.Message); + } + + /// + /// Verify that SaveSessionAsync does not throw when claim is missing in non-strict mode. + /// + [Fact] + public async Task SaveSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: false); + var sessionToSave = new TestAgentSession(); + + // Act - should not throw + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert - conversation ID should use null scope + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + $":{TestConversationId}", + sessionToSave, + It.IsAny()), + Times.Once); + } + + #endregion + + #region Edge Cases + + /// + /// Verify behavior when HttpContextAccessor returns null HttpContext. + /// + [Fact] + public async Task WhenHttpContextIsNullAndStrictThrowsAsync() + { + // Arrange + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: true); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); + } + + /// + /// Verify behavior when HttpContextAccessor returns null HttpContext in non-strict mode. + /// + [Fact] + public async Task WhenHttpContextIsNullAndNonStrictProceedsAsync() + { + // Arrange + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); + var store = new UserIdentityScopedSessionStore( + this._innerStoreMock.Object, + this._httpContextAccessorMock.Object, + strict: false); + + // Act - should not throw + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $":{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that different users get different scoped conversation IDs. + /// + [Fact] + public async Task DifferentUsersGetDifferentScopedConversationIdsAsync() + { + // Arrange + string? capturedConversationId1 = null; + string? capturedConversationId2 = null; + + this._innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, conversationId, _) => + { + if (capturedConversationId1 == null) + { + capturedConversationId1 = conversationId; + } + else + { + capturedConversationId2 = conversationId; + } + }) + .ReturnsAsync(this._testSession); + + // Act - User 1 + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User1); + var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Act - User 2 + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User2); + var store2 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + Assert.Equal($"{User1}:{TestConversationId}", capturedConversationId1); + Assert.Equal($"{User2}:{TestConversationId}", capturedConversationId2); + Assert.NotEqual(capturedConversationId1, capturedConversationId2); + } + + #endregion + + #region Helper Methods + + private void SetupHttpContextWithClaim(string claimType, string claimValue) + { + var claims = new[] { new Claim(claimType, claimValue) }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +}