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
-}