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