From 3f9ae8334c847ea4df8dd8ba9813185fdcc1c9e0 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 09:48:47 -0400 Subject: [PATCH 01/17] feat: Add DelegatingAgentSessionStore Add helper for decorator pattern for AgentSessionStore --- .../DelegatingAgentSessionStore.cs | 62 ++++++ .../DelegatingAgentSessionStoreTests.cs | 207 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs new file mode 100644 index 0000000000..a9c3d436df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides an abstract base class for agent session stores that delegate operations to an inner store +/// instance while allowing for extensibility and customization. +/// +/// +/// +/// implements the decorator pattern for s, +/// enabling the creation of pipeliens where each layer can add functionality while delegating core operations to an +/// underlying store. +/// +/// +/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store. +/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store +/// interface. +/// +/// +public class DelegatingAgentSessionStore : AgentSessionStore +{ + /// + /// Initializes a new instance of the class with the specified inner + /// store. + /// + /// The underlying session store instance that will handle the core operations. + /// is . + /// + /// The inner session store serves as the foundation of the delegation chain. All operations not overridden by + /// derived classes will be forwarded to this store. + /// + protected DelegatingAgentSessionStore(AgentSessionStore innerStore) + { + this.InnerStore = Throw.IfNull(innerStore); + } + + /// + /// Gets the inner session store instance that receives delegated operations. + /// + /// + /// The underlying instance that handles core storage operations. + /// + /// + /// Derived classes can use this property to access the inner session store for custom delegation scenarios + /// or to forward operations with additional processing. + /// + protected AgentSessionStore InnerStore { get; } + + /// + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, conversationId, cancellationToken); + + /// + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, 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 new file mode 100644 index 0000000000..5a55b6e67b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegatingAgentSessionStoreTests +{ + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly TestDelegatingAgentSessionStore _delegatingStore; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public DelegatingAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._testSession = new TestAgentSession(); + + // Setup inner store mock + 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); + + this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() => + // Act & Assert + Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); + + /// + /// Verify that constructor sets the inner store correctly. + /// + [Fact] + public void Constructor_WithValidInnerStore_SetsInnerStore() + { + // Act + var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + + // Assert + Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); + } + + #endregion + + #region Method Delegation Tests + + /// + /// Verify that GetSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task GetSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + 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(ct => ct == expectedCancellationToken))) + .ReturnsAsync(this._testSession); + + // Act + var session = await this._delegatingStore.GetSessionAsync( + this._agentMock.Object, + expectedConversationId, + expectedCancellationToken); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + expectedConversationId, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + 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(s => s == expectedSession), + It.Is(ct => ct == expectedCancellationToken))) + .Returns(ValueTask.CompletedTask); + + // Act + await this._delegatingStore.SaveSessionAsync( + this._agentMock.Object, + expectedConversationId, + expectedSession, + expectedCancellationToken); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + expectedConversationId, + expectedSession, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that GetSessionAsync awaits the inner store's result before returning. + /// + [Fact] + public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() + { + // Arrange + const string expectedConversationId = "test-conversation-id"; + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, expectedConversationId); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(this._testSession); + Assert.True(resultTask.IsCompleted); + Assert.Same(this._testSession, await resultTask); + } + + /// + /// Verify that SaveSessionAsync awaits the inner store's completion before returning. + /// + [Fact] + public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() + { + // Arrange + const string expectedConversationId = "test-conversation-id"; + var expectedSession = new TestAgentSession(); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, expectedConversationId, expectedSession); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + #endregion + + #region Test Implementation + + /// + /// Test implementation of DelegatingAgentSessionStore for testing purposes. + /// + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) + { + public new AgentSessionStore InnerStore => base.InnerStore; + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +} From f346f2a618d71d56e2972d868495339648f80b48 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 09:50:23 -0400 Subject: [PATCH 02/17] 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. --- 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 e2ae05c3b4..3916ad3fb6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -604,6 +604,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 void Constructor_WithValidInnerStore_SetsInnerStore() 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 async Task GetSessionAsyncDelegatesToInnerStoreAsync() this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedCancellationToken), Times.Once); } @@ -105,14 +105,14 @@ public async Task GetSessionAsyncDelegatesToInnerStoreAsync() 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 async Task SaveSessionAsyncDelegatesToInnerStoreAsync() // Act await this._delegatingStore.SaveSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedSession, expectedCancellationToken); @@ -128,7 +128,7 @@ await this._delegatingStore.SaveSessionAsync( this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - expectedConversationId, + ExpectedConversationId, expectedSession, expectedCancellationToken), Times.Once); @@ -141,7 +141,7 @@ await this._delegatingStore.SaveSessionAsync( 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 async Task GetSessionAsyncAwaitsInnerStoreResultAsync() 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 async Task GetSessionAsyncAwaitsInnerStoreResultAsync() 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 async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() 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 +} From cebd3512cd07d48e85c6090dc5cd421ebd56ed01 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 11:51:24 -0400 Subject: [PATCH 03/17] fix: Harden scope mapping --- .../UserIdentityScopedSessionStore.cs | 18 ++- .../UserIdentityScopedSessionStoreTests.cs | 114 ++++++++++++++++-- 2 files changed, 117 insertions(+), 15 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs index b1eb6cbf57..c1d6136239 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -36,7 +37,7 @@ public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore /// /// /// If , an exception is thrown when the specified claim is not found. - /// If , operations proceed without scoping when the claim is absent. + /// If , the conversation ID is passed through unmodified when the claim is absent. /// public UserIdentityScopedSessionStore(AgentSessionStore innerStore, IHttpContextAccessor? contextAccessor, @@ -44,7 +45,7 @@ public UserIdentityScopedSessionStore(AgentSessionStore innerStore, bool strict = true) : base(innerStore) { this._httpContextAccessor = contextAccessor; - this._claimType = claimType; + this._claimType = Throw.IfNullOrWhitespace(claimType); this._strict = strict; } @@ -64,7 +65,18 @@ public UserIdentityScopedSessionStore(AgentSessionStore innerStore, private string? ScopeId => this.GetScopeFromIdentity(); - private string GetScopedConversationId(string bareConversationId) => $"{this.ScopeId}:{bareConversationId}"; + private static string EscapeScopeId(string scopeId) => scopeId.Replace("\\", "\\\\").Replace(":", "\\:"); + + private string GetScopedConversationId(string bareConversationId) + { + string? scopeId = this.ScopeId; + if (scopeId == null) + { + return bareConversationId; + } + + return $"{EscapeScopeId(scopeId)}::{bareConversationId}"; + } /// public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs index 545dd92915..504c4177f7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs @@ -65,6 +65,27 @@ public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() Assert.NotNull(store); } + /// + /// Verify that constructor throws ArgumentException when claimType is null. + /// + [Fact] + public void RequiresClaimType_NotNull() => + Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: null!)); + + /// + /// Verify that constructor throws ArgumentException when claimType is empty. + /// + [Fact] + public void RequiresClaimType_NotEmpty() => + Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: string.Empty)); + + /// + /// Verify that constructor throws ArgumentException when claimType is whitespace. + /// + [Fact] + public void RequiresClaimType_NotWhitespace() => + Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: " ")); + #endregion #region GetSessionAsync Tests @@ -86,7 +107,7 @@ public async Task GetSessionAsyncScopesConversationIdWithUserClaimAsync() this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - $"{TestUserId}:{TestConversationId}", + $"{TestUserId}::{TestConversationId}", It.IsAny()), Times.Once); } @@ -111,7 +132,7 @@ public async Task GetSessionAsyncUsesCustomClaimTypeAsync() this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - $"{CustomClaimValue}:{TestConversationId}", + $"{CustomClaimValue}::{TestConversationId}", It.IsAny()), Times.Once); } @@ -152,11 +173,11 @@ public async Task GetSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsyn // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - // Assert - conversation ID should use null scope + // Assert - conversation ID should be passed through unmodified this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - $":{TestConversationId}", + TestConversationId, It.IsAny()), Times.Once); } @@ -200,7 +221,7 @@ public async Task SaveSessionAsyncScopesConversationIdWithUserClaimAsync() this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - $"{TestUserId}:{TestConversationId}", + $"{TestUserId}::{TestConversationId}", sessionToSave, It.IsAny()), Times.Once); @@ -227,7 +248,7 @@ public async Task SaveSessionAsyncUsesCustomClaimTypeAsync() this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - $"{CustomClaimValue}:{TestConversationId}", + $"{CustomClaimValue}::{TestConversationId}", sessionToSave, It.IsAny()), Times.Once); @@ -271,11 +292,11 @@ public async Task SaveSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsy // Act - should not throw await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - // Assert - conversation ID should use null scope + // Assert - conversation ID should be passed through unmodified this._innerStoreMock.Verify( x => x.SaveSessionAsync( this._agentMock.Object, - $":{TestConversationId}", + TestConversationId, sessionToSave, It.IsAny()), Times.Once); @@ -319,11 +340,11 @@ public async Task WhenHttpContextIsNullAndNonStrictProceedsAsync() // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - // Assert + // Assert - conversation ID should be passed through unmodified this._innerStoreMock.Verify( x => x.GetSessionAsync( this._agentMock.Object, - $":{TestConversationId}", + TestConversationId, It.IsAny()), Times.Once); } @@ -364,11 +385,80 @@ public async Task DifferentUsersGetDifferentScopedConversationIdsAsync() await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert - Assert.Equal($"{User1}:{TestConversationId}", capturedConversationId1); - Assert.Equal($"{User2}:{TestConversationId}", capturedConversationId2); + Assert.Equal($"{User1}::{TestConversationId}", capturedConversationId1); + Assert.Equal($"{User2}::{TestConversationId}", capturedConversationId2); Assert.NotEqual(capturedConversationId1, capturedConversationId2); } + /// + /// Verify that colons in user claim values are escaped. + /// + [Fact] + public async Task EscapesColonsInUserClaimValueAsync() + { + // Arrange + const string UserIdWithColon = "user:with:colons"; + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithColon); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - colons should be escaped as \: + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"user\\:with\\:colons::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that backslashes in user claim values are escaped. + /// + [Fact] + public async Task EscapesBackslashesInUserClaimValueAsync() + { + // Arrange + const string UserIdWithBackslash = @"domain\user"; + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBackslash); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes should be escaped as \\ + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\user::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that both backslashes and colons in user claim values are escaped correctly. + /// + [Fact] + public async Task EscapesBothBackslashesAndColonsInUserClaimValueAsync() + { + // Arrange + const string UserIdWithBoth = @"domain\user:role"; + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBoth); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes escaped first, then colons + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\user\\:role::{TestConversationId}", + It.IsAny()), + Times.Once); + } + #endregion #region Helper Methods From 4e18808443e4af6f2b1973c20fa5ef653f2a70bd Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 12:49:08 -0400 Subject: [PATCH 04/17] fix: Add UserIdentityScopeSessionStoreOptions to avoid future breaking changes --- .../UserIdentityScopedSessionStore.cs | 22 +++--- .../UserIdentityScopedSessionStoreOptions.cs | 29 ++++++++ .../DelegatingAgentSessionStore.cs | 4 +- .../UserIdentityScopedSessionStoreTests.cs | 69 +++++++++++++------ 4 files changed, 88 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs index c1d6136239..8d9416f002 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs @@ -32,21 +32,17 @@ public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore /// /// 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 , the conversation ID is passed through unmodified when the claim is absent. - /// - public UserIdentityScopedSessionStore(AgentSessionStore innerStore, - IHttpContextAccessor? contextAccessor, - string claimType = ClaimsIdentity.DefaultNameClaimType, - bool strict = true) : base(innerStore) + /// The options for configuring the session store. If null, defaults are used. + public UserIdentityScopedSessionStore( + AgentSessionStore innerStore, + IHttpContextAccessor? contextAccessor, + UserIdentityScopedSessionStoreOptions? options = null) : base(innerStore) { + options ??= new UserIdentityScopedSessionStoreOptions(); + this._httpContextAccessor = contextAccessor; - this._claimType = Throw.IfNullOrWhitespace(claimType); - this._strict = strict; + this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); + this._strict = options.Strict; } private string? GetScopeFromIdentity() diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs new file mode 100644 index 0000000000..40c8b08b1b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Claims; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class UserIdentityScopedSessionStoreOptions +{ + /// + /// Gets or sets the claim type to extract from the user's identity for scoping. + /// + /// + /// Defaults to . + /// + public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; + + /// + /// Gets or sets a value indicating whether an exception should be thrown when the specified claim is not found. + /// + /// + /// If , an exception is thrown when the specified claim is not found. + /// If , the conversation ID is passed through unmodified when the claim is absent. + /// Defaults to . + /// + public bool Strict { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index a9c3d436df..6e8314a74c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// implements the decorator pattern for s, -/// enabling the creation of pipeliens where each layer can add functionality while delegating core operations to an +/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an /// underlying store. /// /// @@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Hosting; /// interface. /// /// -public class DelegatingAgentSessionStore : AgentSessionStore +public abstract class DelegatingAgentSessionStore : AgentSessionStore { /// /// Initializes a new instance of the class with the specified inner diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs index 504c4177f7..3098ad9021 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs @@ -52,7 +52,23 @@ public UserIdentityScopedSessionStoreTests() /// [Fact] public void RequiresInnerStore() => - Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object)); + Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object, CreateOptions())); + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + + // Act - should not throw and use default claim type + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, options: null); + + // Assert + Assert.NotNull(store); + } /// /// Verify that constructor accepts null IHttpContextAccessor. @@ -61,7 +77,7 @@ public void RequiresInnerStore() => public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() { // Act & Assert - should not throw - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, strict: false); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, CreateOptions(strict: false)); Assert.NotNull(store); } @@ -70,21 +86,21 @@ public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() /// [Fact] public void RequiresClaimType_NotNull() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: null!)); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: null!))); /// /// Verify that constructor throws ArgumentException when claimType is empty. /// [Fact] public void RequiresClaimType_NotEmpty() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: string.Empty)); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: string.Empty))); /// /// Verify that constructor throws ArgumentException when claimType is whitespace. /// [Fact] public void RequiresClaimType_NotWhitespace() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: " ")); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: " "))); #endregion @@ -98,7 +114,7 @@ public async Task GetSessionAsyncScopesConversationIdWithUserClaimAsync() { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -123,7 +139,7 @@ public async Task GetSessionAsyncUsesCustomClaimTypeAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - claimType: CustomClaimType); + CreateOptions(claimType: CustomClaimType)); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -148,7 +164,7 @@ public async Task GetSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -168,7 +184,7 @@ public async Task GetSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsyn var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -190,7 +206,7 @@ public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -211,7 +227,7 @@ public async Task SaveSessionAsyncScopesConversationIdWithUserClaimAsync() { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); var sessionToSave = new TestAgentSession(); // Act @@ -238,7 +254,7 @@ public async Task SaveSessionAsyncUsesCustomClaimTypeAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - claimType: CustomClaimType); + CreateOptions(claimType: CustomClaimType)); var sessionToSave = new TestAgentSession(); // Act @@ -265,7 +281,7 @@ public async Task SaveSessionAsyncThrowsWhenClaimMissingInStrictModeAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); var sessionToSave = new TestAgentSession(); // Act & Assert @@ -286,7 +302,7 @@ public async Task SaveSessionAsyncDoesNotThrowWhenClaimMissingInNonStrictModeAsy var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); var sessionToSave = new TestAgentSession(); // Act - should not throw @@ -317,7 +333,7 @@ public async Task WhenHttpContextIsNullAndStrictThrowsAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); // Act & Assert await Assert.ThrowsAsync( @@ -335,7 +351,7 @@ public async Task WhenHttpContextIsNullAndNonStrictProceedsAsync() var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -376,12 +392,12 @@ public async Task DifferentUsersGetDifferentScopedConversationIdsAsync() // Act - User 1 this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User1); - var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); 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); + var store2 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert @@ -399,7 +415,7 @@ public async Task EscapesColonsInUserClaimValueAsync() // Arrange const string UserIdWithColon = "user:with:colons"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithColon); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -422,7 +438,7 @@ public async Task EscapesBackslashesInUserClaimValueAsync() // Arrange const string UserIdWithBackslash = @"domain\user"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBackslash); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -445,7 +461,7 @@ public async Task EscapesBothBackslashesAndColonsInUserClaimValueAsync() // Arrange const string UserIdWithBoth = @"domain\user:role"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBoth); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -463,6 +479,17 @@ public async Task EscapesBothBackslashesAndColonsInUserClaimValueAsync() #region Helper Methods + private static UserIdentityScopedSessionStoreOptions CreateOptions( + string? claimType = ClaimsIdentity.DefaultNameClaimType, + bool strict = true) + { + return new UserIdentityScopedSessionStoreOptions + { + ClaimType = claimType!, + Strict = strict + }; + } + private void SetupHttpContextWithClaim(string claimType, string claimValue) { var claims = new[] { new Claim(claimType, claimValue) }; From aa67761bee83c1c6b699e17f1df7285baf737825 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 10:30:58 -0400 Subject: [PATCH 05/17] Split UserIdentityScopedSessionStore into a separate IsolationKeyProvider and IsolationKeyScopedSessionStore --- ...aimsIdentitySessionIsolationKeyProvider.cs | 78 +++ ...ntitySessionIsolationKeyProviderOptions.cs | 30 ++ .../UserIdentityScopedSessionStore.cs | 84 --- .../UserIdentityScopedSessionStoreOptions.cs | 29 - .../IsolationKeyScopedAgentSessionStore.cs | 108 ++++ ...lationKeyScopedAgentSessionStoreOptions.cs | 25 + .../SessionIsolationKeyProvider.cs | 39 ++ ...dentitySessionIsolationKeyProviderTests.cs | 251 +++++++++ ...solationKeyScopedAgentSessionStoreTests.cs | 390 ++++++++++++++ .../SessionIsolationKeyProviderTests.cs | 95 ++++ .../UserIdentityScopedSessionStoreTests.cs | 510 ------------------ 11 files changed, 1016 insertions(+), 623 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs new file mode 100644 index 0000000000..3d61b9bea1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProvider.cs @@ -0,0 +1,78 @@ +// 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; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// A that extracts the session isolation key from a claim +/// in the current user's identity, as provided by ASP.NET Core's . +/// +/// +/// +/// This provider is suitable for ASP.NET Core web applications where session isolation is based on +/// authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier) +/// from the ambient . +/// +/// +/// If the is unavailable, the user is not authenticated, or the specified claim +/// is missing, the provider returns . The consuming +/// will then enforce strict or pass-through behavior based on its configuration. +/// +/// +/// This class relies on , which uses +/// to provide access to the current . +/// +/// +public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider +{ + private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly string _claimType; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The used to retrieve the current HTTP context and user claims. + /// + /// The options for configuring the provider. If null, defaults are used. + /// + /// is null, empty, or whitespace. + /// + public ClaimsIdentitySessionIsolationKeyProvider( + IHttpContextAccessor? httpContextAccessor, + ClaimsIdentitySessionIsolationKeyProviderOptions? options = null) + { + options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions(); + this._httpContextAccessor = httpContextAccessor; + this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); + } + + /// + /// Extracts the session isolation key from the current user's claims. + /// + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the value of the + /// configured claim type from the current user's identity, or if the claim + /// is not present or the HTTP context is unavailable. + /// + /// + /// This method retrieves the claim value from HttpContext.User.Claims. If multiple claims + /// of the specified type exist, the first match is returned. + /// + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + Claim? claim = this._httpContextAccessor? + .HttpContext? + .User?.Claims.FirstOrDefault(c => c.Type == this._claimType); + + return new ValueTask(claim?.Value); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs new file mode 100644 index 0000000000..13845bc680 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentitySessionIsolationKeyProviderOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Claims; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class ClaimsIdentitySessionIsolationKeyProviderOptions +{ + /// + /// Gets or sets the claim type to extract from the user's identity for session isolation. + /// + /// + /// + /// Defaults to , which typically corresponds to + /// the user's name or unique identifier claim. + /// + /// + /// Common alternatives include: + /// + /// ClaimTypes.NameIdentifier — Stable user identifier + /// ClaimTypes.Email — Email address + /// Custom claim types specific to your authentication provider + /// + /// + /// + public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs deleted file mode 100644 index 8d9416f002..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs +++ /dev/null @@ -1,84 +0,0 @@ -// 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; -using Microsoft.Shared.Diagnostics; - -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 options for configuring the session store. If null, defaults are used. - public UserIdentityScopedSessionStore( - AgentSessionStore innerStore, - IHttpContextAccessor? contextAccessor, - UserIdentityScopedSessionStoreOptions? options = null) : base(innerStore) - { - options ??= new UserIdentityScopedSessionStoreOptions(); - - this._httpContextAccessor = contextAccessor; - this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); - this._strict = options.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 static string EscapeScopeId(string scopeId) => scopeId.Replace("\\", "\\\\").Replace(":", "\\:"); - - private string GetScopedConversationId(string bareConversationId) - { - string? scopeId = this.ScopeId; - if (scopeId == null) - { - return bareConversationId; - } - - return $"{EscapeScopeId(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/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs deleted file mode 100644 index 40c8b08b1b..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Security.Claims; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Options for configuring . -/// -public class UserIdentityScopedSessionStoreOptions -{ - /// - /// Gets or sets the claim type to extract from the user's identity for scoping. - /// - /// - /// Defaults to . - /// - public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; - - /// - /// Gets or sets a value indicating whether an exception should be thrown when the specified claim is not found. - /// - /// - /// If , an exception is thrown when the specified claim is not found. - /// If , the conversation ID is passed through unmodified when the claim is absent. - /// Defaults to . - /// - public bool Strict { get; set; } = true; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs new file mode 100644 index 0000000000..a967c35f28 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// A delegating that scopes session keys by an isolation key +/// provided by a , ensuring that sessions are isolated +/// per logical partition (e.g., user, tenant, or composite key). +/// +public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore +{ + private readonly SessionIsolationKeyProvider _keyProvider; + private readonly bool _strict; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying to delegate to. + /// + /// The used to retrieve the isolation key for the current context. + /// + /// The options for configuring the session store. If null, defaults are used. + /// + /// is . + /// + public IsolationKeyScopedAgentSessionStore( + AgentSessionStore innerStore, + SessionIsolationKeyProvider? keyProvider, + IsolationKeyScopedAgentSessionStoreOptions? options = null) + : base(innerStore) + { + this._keyProvider = Throw.IfNull(keyProvider); + options ??= new IsolationKeyScopedAgentSessionStoreOptions(); + this._strict = options.Strict; + } + + /// + /// Asynchronously retrieves the isolation key from the provider and validates it if in strict mode. + /// + /// The cancellation token. + /// + /// The isolation key string, or if no key is available and non-strict mode is enabled. + /// + /// + /// The provider returned and strict mode is enabled. + /// + private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) + { + string? key = await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + + if (this._strict && key == null) + { + throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider."); + } + + return key; + } + + /// + /// Escapes special characters in the isolation key to ensure unambiguous scoped conversation IDs. + /// + /// The raw isolation key. + /// The escaped isolation key. + /// + /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:). + /// This ensures the scoped conversation ID format {key}::{conversationId} can be parsed correctly. + /// + private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); + + /// + /// Constructs a scoped conversation ID by prefixing the bare conversation ID with the escaped isolation key. + /// + /// The original conversation ID. + /// The cancellation token. + /// + /// The scoped conversation ID in the format {escapedKey}::{conversationId}, or the bare conversation ID + /// if no isolation key is available and non-strict mode is enabled. + /// + private async ValueTask GetScopedConversationIdAsync(string bareConversationId, CancellationToken cancellationToken) + { + string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + if (key == null) + { + return bareConversationId; + } + + return $"{EscapeIsolationKey(key)}::{bareConversationId}"; + } + + /// + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedConversationId, cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + string scopedConversationId = await this.GetScopedConversationIdAsync(conversationId, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedConversationId, session, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs new file mode 100644 index 0000000000..94f00f01bb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class IsolationKeyScopedAgentSessionStoreOptions +{ + /// + /// Gets or sets a value indicating whether an exception should be thrown when the isolation key cannot be determined. + /// + /// + /// + /// If (default), the store will throw an + /// when returns . + /// + /// + /// If , the conversation ID is passed through unmodified when the isolation key is absent, + /// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios + /// or mixed environments where not all requests have isolation keys. + /// + /// + public bool Strict { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs new file mode 100644 index 0000000000..61ea82bd34 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/SessionIsolationKeyProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions. +/// +/// +/// +/// Session isolation keys enable multi-tenant or multi-user scenarios by scoping agent session storage +/// to a specific logical partition (e.g., user ID, tenant ID, or composite key). Derived classes +/// implement the key resolution logic appropriate to their hosting environment. +/// +/// +/// When a key is unavailable or cannot be determined, implementations should return . +/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back +/// to unscoped storage based on its configuration. +/// +/// +public abstract class SessionIsolationKeyProvider +{ + /// + /// Asynchronously retrieves the session isolation key for the current request or execution context. + /// + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the isolation key string, + /// or if no key is available in the current context. + /// + /// + /// Implementations should extract the key from ambient context (e.g., HTTP request headers, claims, + /// or environment variables). If the key cannot be determined, return to allow + /// the caller to decide on strict vs. pass-through behavior. + /// + public abstract ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..e22feec1a9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentitySessionIsolationKeyProviderTests.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class ClaimsIdentitySessionIsolationKeyProviderTests +{ + private const string TestUserId = "test-user-id"; + private const string CustomClaimType = "custom-claim-type"; + private const string CustomClaimValue = "custom-claim-value"; + + private readonly Mock _httpContextAccessorMock; + + /// + /// Initializes a new instance of the class. + /// + public ClaimsIdentitySessionIsolationKeyProviderTests() + { + this._httpContextAccessorMock = new Mock(); + } + + #region Constructor Tests + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor accepts null IHttpContextAccessor. + /// + [Fact] + public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() + { + // Act & Assert - should not throw + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + Assert.NotNull(provider); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is null. + /// + [Fact] + public void RequiresClaimType_NotNull() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is empty. + /// + [Fact] + public void RequiresClaimType_NotEmpty() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty })); + } + + /// + /// Verify that constructor throws ArgumentException when claimType is whitespace. + /// + [Fact] + public void RequiresClaimType_NotWhitespace() + { + // Act & Assert + Assert.Throws("options.ClaimType", () => + new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " })); + } + + #endregion + + #region GetSessionIsolationKeyAsync Tests + + /// + /// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(TestUserId, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync() + { + // Arrange + this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue); + var provider = new ClaimsIdentitySessionIsolationKeyProvider( + this._httpContextAccessorMock.Object, + new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType }); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(CustomClaimValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync() + { + // Arrange + this.SetupHttpContextWithClaim("other-claim", "value"); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor returns null HttpContext. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync() + { + // Arrange + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify behavior when HttpContextAccessor itself is null. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync() + { + // Arrange + var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync() + { + // Arrange + const string FirstValue = "first-value"; + const string SecondValue = "second-value"; + var claims = new[] + { + new Claim(ClaimsIdentity.DefaultNameClaimType, FirstValue), + new Claim(ClaimsIdentity.DefaultNameClaimType, SecondValue), + }; + var identity = new ClaimsIdentity(claims); + var principal = new ClaimsPrincipal(identity); + + var httpContext = new DefaultHttpContext + { + User = principal + }; + + this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(FirstValue, result); + } + + /// + /// Verify that GetSessionIsolationKeyAsync handles empty claim values. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, string.Empty); + var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(string.Empty, result); + } + + #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); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs new file mode 100644 index 0000000000..f2635162ae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for . +/// +public class IsolationKeyScopedAgentSessionStoreTests +{ + private const string TestIsolationKey = "test-key"; + private const string TestConversationId = "test-conversation-id"; + + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public IsolationKeyScopedAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = 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() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + + // Act & Assert + Assert.Throws("innerStore", () => + new IsolationKeyScopedAgentSessionStore(null!, provider)); + } + + /// + /// Verify that constructor throws ArgumentNullException when keyProvider is null. + /// + [Fact] + public void RequiresKeyProvider() + { + // Act & Assert + Assert.Throws("keyProvider", () => + new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, null!)); + } + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + + // Act & Assert - should not throw + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null); + Assert.NotNull(store); + } + + #endregion + + #region GetSessionAsync Tests + + /// + /// Verify that GetSessionAsync scopes the conversation ID with the isolation key. + /// + [Fact] + public async Task GetSessionAsyncScopesConversationIdWithKeyAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"{TestIsolationKey}::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode. + /// + [Fact] + public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); + + Assert.Contains("Session isolation key is required", exception.Message); + } + + /// + /// Verify that GetSessionAsync does not throw when key is null in non-strict mode. + /// + [Fact] + public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); + + // Act - should not throw + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - conversation ID should be passed through unmodified + 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 + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // 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 isolation key. + /// + [Fact] + public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var sessionToSave = new TestAgentSession(); + + // Act + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + $"{TestIsolationKey}::{TestConversationId}", + sessionToSave, + It.IsAny()), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode. + /// + [Fact] + public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); + var sessionToSave = new TestAgentSession(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); + + Assert.Contains("Session isolation key is required", exception.Message); + } + + /// + /// Verify that SaveSessionAsync does not throw when key is null in non-strict mode. + /// + [Fact] + public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + var store = new IsolationKeyScopedAgentSessionStore( + this._innerStoreMock.Object, + provider, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); + var sessionToSave = new TestAgentSession(); + + // Act - should not throw + await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + + // Assert - conversation ID should be passed through unmodified + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + TestConversationId, + sessionToSave, + It.IsAny()), + Times.Once); + } + + #endregion + + #region Escaping Tests + + /// + /// Verify that colons in the isolation key are escaped. + /// + [Fact] + public async Task EscapesColonsInIsolationKeyAsync() + { + // Arrange + const string KeyWithColon = "key:with:colons"; + var provider = new TestSessionIsolationKeyProvider(KeyWithColon); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - colons should be escaped as \: + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"key\\:with\\:colons::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that backslashes in the isolation key are escaped. + /// + [Fact] + public async Task EscapesBackslashesInIsolationKeyAsync() + { + // Arrange + const string KeyWithBackslash = @"domain\key"; + var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes should be escaped as \\ + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\key::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + /// + /// Verify that both backslashes and colons in the isolation key are escaped correctly. + /// + [Fact] + public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync() + { + // Arrange + const string KeyWithBoth = @"domain\key:role"; + var provider = new TestSessionIsolationKeyProvider(KeyWithBoth); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert - backslashes escaped first, then colons + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + $"domain\\\\key\\:role::{TestConversationId}", + It.IsAny()), + Times.Once); + } + + #endregion + + #region Isolation Tests + + /// + /// Verify that different isolation keys result in different scoped conversation IDs. + /// + [Fact] + public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() + { + // Arrange + const string Key1 = "key-1"; + const string Key2 = "key-2"; + 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 - Key 1 + var provider1 = new TestSessionIsolationKeyProvider(Key1); + var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1); + await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Act - Key 2 + var provider2 = new TestSessionIsolationKeyProvider(Key2); + var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2); + await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); + + // Assert + Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1); + Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2); + Assert.NotEqual(capturedConversationId1, capturedConversationId2); + } + + #endregion + + #region Helper Classes + + /// + /// Test implementation of for testing purposes. + /// + private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + private readonly string? _key; + + public TestSessionIsolationKeyProvider(string? key) + { + this._key = key; + } + + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(this._key); + } + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs new file mode 100644 index 0000000000..00bf2cd373 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/SessionIsolationKeyProviderTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +/// +/// Unit tests for and its contract. +/// +public class SessionIsolationKeyProviderTests +{ + /// + /// Verify that a concrete provider can return a non-null isolation key. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync() + { + // Arrange + const string ExpectedKey = "test-key"; + var provider = new TestSessionIsolationKeyProvider(ExpectedKey); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Equal(ExpectedKey, result); + } + + /// + /// Verify that a concrete provider can return null when no key is available. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(null); + + // Act + string? result = await provider.GetSessionIsolationKeyAsync(); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that cancellation token is passed through to the provider implementation. + /// + [Fact] + public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync() + { + // Arrange + var provider = new TestCancellableSessionIsolationKeyProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await provider.GetSessionIsolationKeyAsync(cts.Token)); + } + + #region Test Implementations + + /// + /// Test implementation of for testing purposes. + /// + private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + private readonly string? _key; + + public TestSessionIsolationKeyProvider(string? key) + { + this._key = key; + } + + public override ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + return new ValueTask(this._key); + } + } + + /// + /// Test implementation that respects cancellation tokens. + /// + private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider + { + public override async ValueTask GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default) + { + await Task.Delay(1000, cancellationToken); + return "key"; + } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs deleted file mode 100644 index 3098ad9021..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs +++ /dev/null @@ -1,510 +0,0 @@ -// 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, CreateOptions())); - - /// - /// Verify that constructor uses default options when options is null. - /// - [Fact] - public void UsesDefaultOptionsWhenNull() - { - // Arrange - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - - // Act - should not throw and use default claim type - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, options: null); - - // Assert - Assert.NotNull(store); - } - - /// - /// 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, CreateOptions(strict: false)); - Assert.NotNull(store); - } - - /// - /// Verify that constructor throws ArgumentException when claimType is null. - /// - [Fact] - public void RequiresClaimType_NotNull() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: null!))); - - /// - /// Verify that constructor throws ArgumentException when claimType is empty. - /// - [Fact] - public void RequiresClaimType_NotEmpty() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: string.Empty))); - - /// - /// Verify that constructor throws ArgumentException when claimType is whitespace. - /// - [Fact] - public void RequiresClaimType_NotWhitespace() => - Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: " "))); - - #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, CreateOptions()); - - // 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, - CreateOptions(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, - CreateOptions(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, - CreateOptions(strict: false)); - - // Act - should not throw - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - conversation ID should be passed through unmodified - 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, CreateOptions()); - - // 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, CreateOptions()); - 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, - CreateOptions(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, - CreateOptions(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, - CreateOptions(strict: false)); - var sessionToSave = new TestAgentSession(); - - // Act - should not throw - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - - // Assert - conversation ID should be passed through unmodified - 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, - CreateOptions(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, - CreateOptions(strict: false)); - - // Act - should not throw - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - conversation ID should be passed through unmodified - 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, CreateOptions()); - 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, CreateOptions()); - await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - Assert.Equal($"{User1}::{TestConversationId}", capturedConversationId1); - Assert.Equal($"{User2}::{TestConversationId}", capturedConversationId2); - Assert.NotEqual(capturedConversationId1, capturedConversationId2); - } - - /// - /// Verify that colons in user claim values are escaped. - /// - [Fact] - public async Task EscapesColonsInUserClaimValueAsync() - { - // Arrange - const string UserIdWithColon = "user:with:colons"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithColon); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - colons should be escaped as \: - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"user\\:with\\:colons::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that backslashes in user claim values are escaped. - /// - [Fact] - public async Task EscapesBackslashesInUserClaimValueAsync() - { - // Arrange - const string UserIdWithBackslash = @"domain\user"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBackslash); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes should be escaped as \\ - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\user::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that both backslashes and colons in user claim values are escaped correctly. - /// - [Fact] - public async Task EscapesBothBackslashesAndColonsInUserClaimValueAsync() - { - // Arrange - const string UserIdWithBoth = @"domain\user:role"; - this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBoth); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes escaped first, then colons - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\user\\:role::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - #endregion - - #region Helper Methods - - private static UserIdentityScopedSessionStoreOptions CreateOptions( - string? claimType = ClaimsIdentity.DefaultNameClaimType, - bool strict = true) - { - return new UserIdentityScopedSessionStoreOptions - { - ClaimType = claimType!, - Strict = strict - }; - } - - 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 -} From 240f36b24dea93f235229b79e2086e0bef22218f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 11:26:55 -0400 Subject: [PATCH 06/17] Add GetService<>() capabilities to interrogate AgentSessionStore delegation chain --- .../AgentSessionStore.cs | 33 +++ .../DelegatingAgentSessionStore.cs | 19 ++ .../DelegatingAgentSessionStoreTests.cs | 193 ++++++++++++++++++ ...solationKeyScopedAgentSessionStoreTests.cs | 51 +++++ 4 files changed, 296 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 2f57e26409..1d5373e38a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -43,4 +45,35 @@ public abstract ValueTask GetSessionAsync( AIAgent agent, string conversationId, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains + /// to verify that specific store implementations are present. + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; + } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains + /// to verify that specific store implementations are present. + /// + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index 6e8314a74c..e80d3b907a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -59,4 +59,23 @@ public override ValueTask GetSessionAsync(AIAgent agent, string co /// public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) => this.InnerStore.SaveSessionAsync(agent, conversationId, session, cancellationToken); + + /// + /// + /// This implementation first checks if this instance satisfies the service request. + /// If not, it chains the request to the inner store, allowing services to be retrieved + /// from any store in the delegation chain. + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + // First, check if this instance satisfies the request + object? service = base.GetService(serviceType, serviceKey); + if (service is not null) + { + return service; + } + + // Chain to the inner store + return this.InnerStore.GetService(serviceType, serviceKey); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs index f73776f172..f04ad7c20d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -191,6 +191,182 @@ public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() #endregion + #region GetService Tests + + /// + /// Verify that GetService returns itself when requesting the exact type. + /// + [Fact] + public void GetServiceReturnsItselfForExactType() + { + // Act + var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService returns itself when requesting a base type. + /// + [Fact] + public void GetServiceReturnsItselfForBaseType() + { + // Act + var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService returns itself when requesting AgentSessionStore. + /// + [Fact] + public void GetServiceReturnsItselfForAgentSessionStoreType() + { + // Act + var result = this._delegatingStore.GetService(typeof(AgentSessionStore)); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService chains to inner store when type is not satisfied by outer store. + /// + [Fact] + public void GetServiceChainsToInnerStore() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore)); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService chains through multiple delegation layers. + /// + [Fact] + public void GetServiceChainsThoughMultipleDelegationLayers() + { + // Arrange - create a three-layer chain: outer -> middle -> inner + var innerStore = new ConcreteAgentSessionStore(); + var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); + var outerStore = new TestDelegatingAgentSessionStore(middleStore); + + // Act - request the innermost store type + var result = outerStore.GetService(typeof(ConcreteAgentSessionStore)); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService can find a store in the middle of the delegation chain. + /// + [Fact] + public void GetServiceFindsMiddleStoreInChain() + { + // Arrange - create a three-layer chain: outer -> middle -> inner + var innerStore = new ConcreteAgentSessionStore(); + var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); + var outerStore = new TestDelegatingAgentSessionStore(middleStore); + + // Act - request the middle store type + var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore)); + + // Assert + Assert.Same(middleStore, result); + } + + /// + /// Verify that GetService returns null when the requested type is not found in the chain. + /// + [Fact] + public void GetServiceReturnsNullWhenTypeNotFound() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(typeof(string)); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService returns null when a service key is provided but not matched. + /// + [Fact] + public void GetServiceReturnsNullWhenServiceKeyProvided() + { + // Act + var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key"); + + // Assert + Assert.Null(result); + } + + /// + /// Verify that GetService throws ArgumentNullException when serviceType is null. + /// + [Fact] + public void GetServiceThrowsWhenServiceTypeIsNull() => + Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); + + /// + /// Verify that GetService generic method works correctly. + /// + [Fact] + public void GetServiceGenericReturnsItself() + { + // Act + var result = this._delegatingStore.GetService(); + + // Assert + Assert.Same(this._delegatingStore, result); + } + + /// + /// Verify that GetService generic method chains to inner store. + /// + [Fact] + public void GetServiceGenericChainsToInnerStore() + { + // Arrange + var innerStore = new ConcreteAgentSessionStore(); + var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); + + // Act + var result = delegatingStore.GetService(); + + // Assert + Assert.Same(innerStore, result); + } + + /// + /// Verify that GetService generic method returns null when type not found. + /// + [Fact] + public void GetServiceGenericReturnsNullWhenTypeNotFound() + { + // Act + var result = this._delegatingStore.GetService(); + + // Assert + Assert.Null(result); + } + + #endregion + #region Test Implementation /// @@ -201,6 +377,23 @@ private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStor public new AgentSessionStore InnerStore => base.InnerStore; } + /// + /// Another delegating store implementation for testing multi-layer chains. + /// + private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); + + /// + /// Concrete (non-delegating) session store for testing GetService chaining. + /// + private sealed class ConcreteAgentSessionStore : AgentSessionStore + { + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => new(new TestAgentSession()); + + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + private sealed class TestAgentSession : AgentSession; #endregion diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index f2635162ae..18fe3095e3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -364,6 +364,45 @@ public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() #endregion + #region GetService Tests + + /// + /// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain. + /// + [Fact] + public void GetServiceReturnsIsolationKeyScopedAgentSessionStore() + { + // Arrange + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + + // Act + var result = store.GetService(); + + // Assert + Assert.Same(store, result); + } + + /// + /// Verify that GetService chains through to find inner store types. + /// + [Fact] + public void GetServiceChainsToInnerStore() + { + // Arrange + var concreteInnerStore = new ConcreteAgentSessionStore(); + var provider = new TestSessionIsolationKeyProvider(TestIsolationKey); + var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); + + // Act + var result = store.GetService(); + + // Assert + Assert.Same(concreteInnerStore, result); + } + + #endregion + #region Helper Classes /// @@ -386,5 +425,17 @@ public TestSessionIsolationKeyProvider(string? key) private sealed class TestAgentSession : AgentSession; + /// + /// Concrete (non-delegating) session store for testing GetService chaining. + /// + private sealed class ConcreteAgentSessionStore : AgentSessionStore + { + public override ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + => new(new TestAgentSession()); + + public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + #endregion } From a61e0f24d6db7bb5265610dc6eaf1e4533e590bf Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 11:30:42 -0400 Subject: [PATCH 07/17] Harden default for A2A hosting by using an IsolationKeyScopedAgentSessionStore when no store is available. --- .../A2AServerServiceCollectionExtensions.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 29ab28c250..8b7ff6d28b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -140,9 +140,17 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; + // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. + var isolationKeyProvider = serviceProvider.GetService(); + if (agentSessionStore?.GetService() is null) + { + agentSessionStore ??= new InMemoryAgentSessionStore(); + agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new()); + } + var hostAgent = new AIHostAgent( innerAgent: agent, - sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + sessionStore: agentSessionStore); agentHandler = new A2AAgentHandler(hostAgent, runMode); } From 635b4cba88f59225eb72f10268ea89cf888e0464 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 12:37:26 -0400 Subject: [PATCH 08/17] Pipe isolation through Hosting helper extension methods --- ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 1 + .../ServiceCollectionExtensions.cs | 42 ++++++++++++++++++ .../HostedAgentBuilderExtensions.cs | 43 +++++++++++++------ 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 200aa29ccc..b91ea40baa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -27,6 +27,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..1ba2a16db8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Extension methods for configuring AI hosting services in an . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers a that uses claims from the current user's identity + /// to generate session isolation keys. + /// + /// The to add services to. + /// Optional configuration for the claims-based session isolation key provider. + /// The so that additional calls can be chained. + /// + /// This method requires to be registered in the service collection. + /// Ensure that services.AddHttpContextAccessor() has been called before using this method. + /// + public static IServiceCollection UseClaimsBasedSessionIsolation( + this IServiceCollection services, + ClaimsIdentitySessionIsolationKeyProviderOptions? options = null) + { + options ??= new(); + ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Scoped); + services.Add(descriptor); + + return services; + + object CreateIsolationKeyProvider(IServiceProvider serviceProvider) + { + IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService(); + + return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index d1397fcda4..ed11840f5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -3,6 +3,7 @@ using System; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -16,12 +17,11 @@ public static class HostedAgentBuilderExtensions /// Configures the host agent builder to use an in-memory session store for agent session management. /// /// The host agent builder to configure with the in-memory session store. + /// When , wraps the session store with an + /// to provide isolation-key-based scoping for sessions. Defaults to . /// The same instance, configured to use an in-memory session store. - public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder) - { - builder.ServiceCollection.AddKeyedSingleton(builder.Name, new InMemoryAgentSessionStore()); - return builder; - } + public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true) + => builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation); /// /// Registers the specified agent session store with the host agent builder, enabling session-specific storage for @@ -29,12 +29,11 @@ public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuil /// /// The host agent builder to configure with the session store. Cannot be null. /// The agent session store instance to register. Cannot be null. + /// When , wraps the session store with an + /// to provide isolation-key-based scoping for sessions. Defaults to . /// The same host agent builder instance, allowing for method chaining. - public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store) - { - builder.ServiceCollection.AddKeyedSingleton(builder.Name, store); - return builder; - } + public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true) + => builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation); /// /// Configures the host agent builder to use a custom session store implementation for agent sessions. @@ -44,16 +43,36 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil /// name. /// The DI service lifetime for the session store registration. Defaults to /// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime. + /// When , wraps the session store with an + /// to provide isolation-key-based scoping for sessions. Defaults to . /// The same host agent builder instance, enabling further configuration. - public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton) + public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true) { builder.ServiceCollection.AddKeyedService(builder.Name, (sp, key) => { Throw.IfNull(key); var keyString = key as string; Throw.IfNullOrEmpty(keyString); - return createAgentSessionStore(sp, keyString) ?? + + AgentSessionStore store = createAgentSessionStore(sp, keyString) ?? throw new InvalidOperationException($"The agent session store factory did not return a valid {nameof(AgentSessionStore)} instance for key '{keyString}'."); + + if (withIsolation && store.GetService() is null) + { + var isolationKeyProvider = sp.GetService(); + + // Best efforts options getting + IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService(); + if (options is null) + { + var optionsProvider = sp.GetService>(); + options = optionsProvider?.Value; + } + + store = new IsolationKeyScopedAgentSessionStore(store, isolationKeyProvider, options ?? new()); + } + + return store; }, lifetime); return builder; } From 556d2132c560913631256516dfb53eef6673a0bb Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 12:38:29 -0400 Subject: [PATCH 09/17] Add comment to samples about adding SessionIsolationKeyProvider --- .../05-end-to-end/A2AClientServer/A2AServer/Program.cs | 4 ++++ .../05-end-to-end/AGUIClientServer/AGUIServer/Program.cs | 4 ++++ .../AgentWebChat/AgentWebChat.AgentHost/Program.cs | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index c12a1c9431..c694351599 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -101,6 +101,10 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } +// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based +// if using Claims-based Identity for Authentication/Authorization +// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); + builder.AddA2AServer(hostA2AAgent); var app = builder.Build(); diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index a12ca1c5ad..e3b97d34e1 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -49,6 +49,10 @@ AGUIServerSerializerContext.Default.Options) ]); +// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based +// if using Claims-based Identity for Authentication/Authorization +// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); + // Register the agent with the host and configure it to use an in-memory session store // so that conversation state is maintained across requests. In production, you may want to use a persistent session store. builder diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 61cfdcdb68..ca399272b4 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -28,6 +28,10 @@ builder.AddOpenAIChatCompletions(); builder.AddOpenAIResponses(); +// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based +// if using Claims-based Identity for Authentication/Authorization +// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); + var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", @@ -148,6 +152,10 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te pirateAgentBuilder.AddA2AServer(); knightsKnavesAgentBuilder.AddA2AServer(); +// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based +// if using Claims-based Identity for Authentication/Authorization +// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier }); + var app = builder.Build(); app.MapOpenApi(); From 76a4bc1248afd6b40c4ef5467e4c00466a1088e5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 13:15:01 -0400 Subject: [PATCH 10/17] Fix isolation key provider nullability semantics --- .../IsolationKeyScopedAgentSessionStore.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index a967c35f28..d246abeb16 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Hosting; /// public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { - private readonly SessionIsolationKeyProvider _keyProvider; + private readonly SessionIsolationKeyProvider? _keyProvider; private readonly bool _strict; /// @@ -34,7 +34,7 @@ public IsolationKeyScopedAgentSessionStore( IsolationKeyScopedAgentSessionStoreOptions? options = null) : base(innerStore) { - this._keyProvider = Throw.IfNull(keyProvider); + this._keyProvider = keyProvider; options ??= new IsolationKeyScopedAgentSessionStoreOptions(); this._strict = options.Strict; } @@ -51,7 +51,9 @@ public IsolationKeyScopedAgentSessionStore( /// private async ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken) { - string? key = await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + string? key = this._keyProvider != null + ? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false) + : null; if (this._strict && key == null) { From acd6b81db4b4d274dcfb09ff2150fcdaef2937c4 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 13:25:32 -0400 Subject: [PATCH 11/17] fix A2A defaults --- .../A2AServerServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 8b7ff6d28b..deafbb2ced 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -145,7 +145,7 @@ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAge if (agentSessionStore?.GetService() is null) { agentSessionStore ??= new InMemoryAgentSessionStore(); - agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new()); + agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); } var hostAgent = new AIHostAgent( From 0e6e729622a3c7d02e28c9586acec96337ec60c5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 13:36:48 -0400 Subject: [PATCH 12/17] fixup --- .../IsolationKeyScopedAgentSessionStore.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index d246abeb16..f379d625fd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -3,7 +3,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; From 90b0865ad24b08b30bc45a414fa2592f0dd3944e Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 13 May 2026 13:45:35 -0400 Subject: [PATCH 13/17] remove unneeded keyProvider requirement test --- .../IsolationKeyScopedAgentSessionStoreTests.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index 18fe3095e3..d410543608 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -53,17 +53,6 @@ public void RequiresInnerStore() new IsolationKeyScopedAgentSessionStore(null!, provider)); } - /// - /// Verify that constructor throws ArgumentNullException when keyProvider is null. - /// - [Fact] - public void RequiresKeyProvider() - { - // Act & Assert - Assert.Throws("keyProvider", () => - new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, null!)); - } - /// /// Verify that constructor uses default options when options is null. /// From da0d0680926b789f32a0cac25156e2f8e1dad612 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 14:49:26 +0000 Subject: [PATCH 14/17] Add trust-model XML docs to AgentSessionStore, InMemoryAgentSessionStore, MapAGUI, A2A entry points Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/e466c53a-faad-40a8-8b5f-83cf0dce0b1d Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../A2AServerServiceCollectionExtensions.cs | 45 +++++++++++++++++++ .../AGUIEndpointRouteBuilderExtensions.cs | 20 +++++++++ .../AgentSessionStore.cs | 30 +++++++++++++ .../Local/InMemoryAgentSessionStore.cs | 14 ++++++ 4 files changed, 109 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index deafbb2ced..cb0e15dac8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -28,6 +28,23 @@ public static class A2AServerServiceCollectionExtensions /// The agent builder whose name identifies the agent. /// An optional callback to configure . /// The for chaining. + /// + /// + /// Trust model. The A2A contextId arrives from the wire + /// and is treated as a chain-resume identifier — not as an authorization + /// token. The contract carries no principal/owner + /// dimension, so when a persistent store is registered any caller who knows or + /// guesses another caller's contextId can resume that other caller's + /// persisted thread. Hosts that serve more than one user must compose a principal + /// dimension into the lookup key — typically by calling + /// UseClaimsBasedSessionIsolation(...) from + /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom + /// ). When no isolation provider is + /// registered, behavior is unchanged — the bare contextId is used as the + /// conversation identifier, which is appropriate for first-run / single-user / + /// prototyping scenarios but unsafe for multi-user hosts. + /// + /// public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(agentBuilder); @@ -46,6 +63,13 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui /// The name of the agent to create an A2A server for. /// An optional callback to configure . /// The for chaining. + /// + /// See the trust-model remarks on + /// for guidance on multi-user hosts (the wire contextId is a chain-resume + /// identifier, not an authorization token; multi-user hosts must compose a + /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom + /// ). + /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(builder); @@ -65,6 +89,13 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// The agent instance to create an A2A server for. /// An optional callback to configure . /// The for chaining. + /// + /// See the trust-model remarks on + /// for guidance on multi-user hosts (the wire contextId is a chain-resume + /// identifier, not an authorization token; multi-user hosts must compose a + /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom + /// ). + /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(builder); @@ -83,6 +114,13 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// The name of the agent to create an A2A server for. /// An optional callback to configure . /// The for chaining. + /// + /// See the trust-model remarks on + /// for guidance on multi-user hosts (the wire contextId is a chain-resume + /// identifier, not an authorization token; multi-user hosts must compose a + /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom + /// ). + /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(services); @@ -114,6 +152,13 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, /// The agent instance to create an A2A server for. /// An optional callback to configure . /// The for chaining. + /// + /// See the trust-model remarks on + /// for guidance on multi-user hosts (the wire contextId is a chain-resume + /// identifier, not an authorization token; multi-user hosts must compose a + /// principal dimension via UseClaimsBasedSessionIsolation(...) or a custom + /// ). + /// public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) { ArgumentNullException.ThrowIfNull(services); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 948ecdca42..85fd00fb8b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -73,6 +73,26 @@ public static IEndpointConventionBuilder MapAGUI( /// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the /// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted). /// + /// + /// Trust model. The AG-UI RunAgentInput.ThreadId arrives + /// from the wire and is treated as a chain-resume identifier — not as an + /// authorization token. The contract carries no + /// principal/owner dimension, so when a persistent store is registered any caller + /// who knows or guesses another caller's ThreadId can resume that other + /// caller's persisted thread. Hosts that serve more than one user must compose a + /// principal dimension into the lookup key. The recommended way is to wrap the + /// keyed in + /// , typically by calling + /// UseClaimsBasedSessionIsolation(...) from + /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom + /// ) and registering the store via the + /// WithSessionStore(...) / WithInMemorySessionStore(...) helpers on + /// so that the wrapper is applied. When no + /// isolation provider is registered, behavior is unchanged — the bare + /// ThreadId is used as the conversation identifier, which is appropriate + /// for first-run / single-user / prototyping scenarios but unsafe for + /// multi-user hosts. + /// /// public static IEndpointConventionBuilder MapAGUI( this IEndpointRouteBuilder endpoints, diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs index 1d5373e38a..7c0539fe51 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs @@ -11,9 +11,39 @@ namespace Microsoft.Agents.AI.Hosting; /// Defines the contract for storing and retrieving agent conversation threads. /// /// +/// /// Implementations of this interface enable persistent storage of conversation threads, /// allowing conversations to be resumed across HTTP requests, application restarts, /// or different service instances in hosted scenarios. +/// +/// +/// Trust model. The conversationId passed to +/// and typically originates +/// from the wire (for example, an AG-UI RunAgentInput.ThreadId or an A2A +/// contextId). It is a chain-resume identifier, not an authorization +/// token, and the (agent, conversationId) tuple carries no principal/owner +/// dimension. Hosts that serve more than one user from the same registered store must +/// therefore compose a principal dimension into the lookup key, otherwise any caller +/// who knows or guesses another caller's conversationId can resume +/// that other caller's persisted thread. The framework provides +/// as a decorator that rewrites +/// conversationId to include an isolation key resolved from a +/// (for example, the ASP.NET Core +/// ClaimsIdentitySessionIsolationKeyProvider wired up via +/// UseClaimsBasedSessionIsolation(...)). When no provider is registered, the +/// store behaves as a single-namespace persistence layer — appropriate for +/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts. +/// +/// +/// Implementer guidance. Implementations should treat +/// conversationId as opaque: do not parse it, do not impose length +/// or character-set constraints on it, and do not assume it round-trips to the value +/// the caller originally supplied (decorators such as +/// may rewrite it before forwarding). +/// Be aware that any logging, telemetry, or audit sink that surfaces +/// conversationId will also surface the isolation prefix when a +/// scoping decorator is in the chain. +/// /// public abstract class AgentSessionStore { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 9999527505..832c411977 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -24,6 +24,20 @@ namespace Microsoft.Agents.AI.Hosting; /// For production use with multiple instances or persistence across restarts, use a durable storage implementation /// such as Redis, SQL Server, or Azure Cosmos DB. /// +/// +/// Multi-user warning. This store keys threads by +/// (agent.Id, conversationId) only — it has no principal/owner dimension. When +/// the conversation identifier originates from the wire (for example, an AG-UI +/// RunAgentInput.ThreadId or an A2A contextId), any caller who knows +/// or guesses another caller's identifier can resume that other caller's persisted +/// thread. Multi-user hosts must wrap this store in +/// (typically by calling +/// UseClaimsBasedSessionIsolation(...) from +/// Microsoft.Agents.AI.Hosting.AspNetCore or by registering a custom +/// ) so that the conversation namespace is +/// scoped per principal. See the trust-model remarks on +/// for the full background. +/// /// public sealed class InMemoryAgentSessionStore : AgentSessionStore { From 18efa9a529165b208b0239c07ddcb13e7bd7c082 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 14 May 2026 13:54:06 -0400 Subject: [PATCH 15/17] fix: Switch ClaimsBasedIsolationKeyProvider to be Singleton * matches HttpContextAccessor and related MAF services --- .../ServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs index 1ba2a16db8..0ff8d37371 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ServiceCollectionExtensions.cs @@ -27,7 +27,7 @@ public static IServiceCollection UseClaimsBasedSessionIsolation( ClaimsIdentitySessionIsolationKeyProviderOptions? options = null) { options ??= new(); - ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Scoped); + ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton); services.Add(descriptor); return services; From 8381e9cb952190f7c49fff7a1e7970c8186e50b5 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 28 May 2026 06:46:24 -0400 Subject: [PATCH 16/17] release: Ensure new project is in the release filter --- dotnet/agent-framework-release.slnf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index cc84fe6c5a..ea1871619f 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -20,7 +20,8 @@ "src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj", "src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj", "src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj", - "src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj", + "src\\Microsoft.Agents.AI.Hosting.AspNetCore\\Microsoft.Agents.AI.Hosting.AspNetCore.csproj", + "src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj", "src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj", "src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj", "src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj", From 7a1475725dad7f7755d8a58e5b6fcd8cd9dd328f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 28 May 2026 11:11:03 -0400 Subject: [PATCH 17/17] fixup: Integraitaon tests --- .../SessionPersistenceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs index 785a3b2e00..33b842bf34 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -102,7 +102,7 @@ private async Task SetupTestServerWithSessionStoreAsync() // Register agent using hosting DI pattern with InMemorySessionStore builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) - .WithInMemorySessionStore(); + .WithInMemorySessionStore(withIsolation: false); this._app = builder.Build();