.NET: [BREAKING] Rename to AgentIsolationKeyProvider (#7567)

* Update store isolation documentation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Rename store isolation key provider

Rename the shared session isolation abstraction to reflect its use for both session and task stores.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Rename to AgentIsolationKeyProvider per review feedback

Drops the `Store` qualifier and keeps an `Agent` prefix so the type is not
confused with generic isolation-key abstractions from other libraries, while
leaving room for future non-store isolation (memory, retrieval).

- StoreIsolationKeyProvider -> AgentIsolationKeyProvider
- ClaimsIdentityStoreIsolationKeyProvider(+Options) -> ClaimsIdentityAgentIsolationKeyProvider(+Options)
- GetStoreIsolationKeyAsync -> GetIsolationKeyAsync
- UseClaimsBasedStoreIsolation -> UseClaimsBasedAgentIsolation

XML docs now state that the `Agent` prefix identifies the hosting API domain and
does not mean agent instances are isolated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

* Update hosting spec for AgentIsolationKeyProvider rename

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 824a2e29-e79c-42aa-b28e-aa6c12ec3292
This commit is contained in:
SergeyMenshykh
2026-08-07 15:59:07 +01:00
committed by GitHub
parent aaaa56bc60
commit c987529df3
29 changed files with 202 additions and 200 deletions
@@ -172,7 +172,7 @@ parsing a structured payload into a typed record), without coupling the holder t
- Authorize and bind the candidate id to the authenticated principal/tenant before using it as an
`AgentSessionStore` key or a workflow checkpoint session id.
- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via
`UseClaimsBasedSessionIsolation(...)`), so the session namespace is scoped per principal.
`UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal.
- Persist session/checkpoint state only after the run or stream has completed.
## E2E Code Samples
+1 -1
View File
@@ -228,7 +228,7 @@ dotnet run
`ConversationId` keeps request/response continuity. It is not proof that the caller owns that conversation. In multi-user deployments, authenticate each AG-UI request and authorize conversation access using your application's real boundary, such as the authenticated user, tenant, or workspace.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedSessionIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
If your ASP.NET Core host shares session storage across users, pair `MapAGUI` with an isolation strategy such as `UseClaimsBasedAgentIsolation(...)` so the storage key includes a principal-specific dimension instead of relying on the conversation identifier alone.
## Troubleshooting
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -17,9 +17,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -11,9 +11,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,9 +28,9 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -18,9 +18,9 @@ builder.Services.AddAGUIServer();
builder.WebHost.UseUrls("http://localhost:8888");
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -101,10 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -20,9 +20,9 @@ builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.T
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -50,9 +50,9 @@ var agent = new AzureOpenAIClient(
]);
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(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.
@@ -13,9 +13,9 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.AddAGUIServer();
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
// make sure to also register an AgentIsolationKeyProvider to scope sessions by principal in multi-user
// deployments, e.g.:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
WebApplication app = builder.Build();
@@ -28,10 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
@@ -157,10 +157,10 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// IMPORTANT: In production, register an AgentIsolationKeyProvider to isolate sessions and tasks by authenticated caller.
// Without this, contextId/taskId alone are the lookup keys — any caller who knows them can access another caller's data.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// builder.Services.UseClaimsBasedAgentIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -37,9 +37,9 @@ public static class A2AServerServiceCollectionExtensions
/// so when a persistent store is registered any caller who knows or guesses another
/// caller's <c>contextId</c> or <c>taskId</c> can access that other caller's data.
/// Hosts that serve more than one user must compose a principal dimension into the
/// lookup key — typically by calling <c>UseClaimsBasedSessionIsolation(...)</c> from
/// lookup key — typically by calling <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>). When a <see cref="SessionIsolationKeyProvider"/>
/// <see cref="AgentIsolationKeyProvider"/>). When an <see cref="AgentIsolationKeyProvider"/>
/// is registered, both the session store and the task store are automatically wrapped
/// with tenant-scoped isolation. When no isolation provider is registered, behavior
/// is unchanged — the bare identifiers are used directly, which is appropriate for
@@ -68,8 +68,8 @@ public static class A2AServerServiceCollectionExtensions
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or
/// a custom <see cref="SessionIsolationKeyProvider"/>).
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -94,8 +94,8 @@ public static class A2AServerServiceCollectionExtensions
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or
/// a custom <see cref="SessionIsolationKeyProvider"/>).
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -119,8 +119,8 @@ public static class A2AServerServiceCollectionExtensions
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or
/// a custom <see cref="SessionIsolationKeyProvider"/>).
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -157,8 +157,8 @@ public static class A2AServerServiceCollectionExtensions
/// See the trust-model remarks on <see cref="AddA2AServer(IHostedAgentBuilder, Action{A2AServerRegistrationOptions}?)"/>
/// for guidance on multi-user hosts (the wire <c>contextId</c> and <c>taskId</c>
/// are chain-resume identifiers, not authorization tokens; multi-user hosts must
/// compose a principal dimension via <c>UseClaimsBasedSessionIsolation(...)</c> or
/// a custom <see cref="SessionIsolationKeyProvider"/>).
/// compose a principal dimension via <c>UseClaimsBasedAgentIsolation(...)</c> or
/// a custom <see cref="AgentIsolationKeyProvider"/>).
/// </remarks>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
@@ -180,7 +180,7 @@ public static class A2AServerServiceCollectionExtensions
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
{
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
var isolationKeyProvider = serviceProvider.GetService<AgentIsolationKeyProvider>();
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
if (agentHandler is null)
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// A delegating <see cref="ITaskStore"/> that scopes task keys by an isolation key
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that tasks are isolated
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that tasks are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
/// <remarks>
@@ -28,7 +28,7 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
public sealed class IsolationKeyScopedTaskStore : ITaskStore
{
private readonly ITaskStore _innerStore;
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
@@ -36,7 +36,7 @@ public sealed class IsolationKeyScopedTaskStore : ITaskStore
/// </summary>
/// <param name="innerStore">The underlying <see cref="ITaskStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="strict">
/// When <see langword="true"/>, an <see cref="InvalidOperationException"/> is thrown if the isolation key
@@ -45,7 +45,7 @@ public sealed class IsolationKeyScopedTaskStore : ITaskStore
/// <exception cref="ArgumentNullException"><paramref name="innerStore"/> is <see langword="null"/>.</exception>
public IsolationKeyScopedTaskStore(
ITaskStore innerStore,
SessionIsolationKeyProvider? keyProvider,
AgentIsolationKeyProvider? keyProvider,
bool strict)
{
ArgumentNullException.ThrowIfNull(innerStore);
@@ -130,12 +130,12 @@ public sealed class IsolationKeyScopedTaskStore : ITaskStore
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
}
return key;
@@ -91,9 +91,9 @@ public static class AGUIEndpointRouteBuilderExtensions
/// principal dimension into the lookup key. The recommended way is to wrap the
/// keyed <see cref="AgentSessionStore"/> in
/// <see cref="IsolationKeyScopedAgentSessionStore"/>, typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> (or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) and registering the store via the
/// <see cref="AgentIsolationKeyProvider"/>) and registering the store via the
/// <c>WithSessionStore(...)</c> / <c>WithInMemorySessionStore(...)</c> helpers on
/// <see cref="IHostedAgentBuilder"/> so that the wrapper is applied. When no
/// isolation provider is registered, behavior is unchanged — the bare
@@ -113,7 +113,7 @@ public static class AGUIEndpointRouteBuilderExtensions
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
// Ensure that we have an IsolationKeyScopedAgentSessionStore registered.
var isolationKeyProvider = endpoints.ServiceProvider.GetService<SessionIsolationKeyProvider>();
var isolationKeyProvider = endpoints.ServiceProvider.GetService<AgentIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new NoopAgentSessionStore();
@@ -11,63 +11,63 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A <see cref="SessionIsolationKeyProvider"/> that extracts the session isolation key from a claim
/// in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// An <see cref="AgentIsolationKeyProvider"/> that extracts an isolation key for agent-owned resources
/// from a claim in the current user's identity, as provided by ASP.NET Core's <see cref="IHttpContextAccessor"/>.
/// </summary>
/// <remarks>
/// <para>
/// 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)
/// This provider is suitable for ASP.NET Core web applications where agent-owned resources are partitioned
/// by authenticated user identity. It reads a specified claim type (e.g., name, email, or a custom identifier)
/// from the ambient <see cref="HttpContext"/>.
/// </para>
/// <para>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>
/// <strong>Security warning:</strong> The configured <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>
/// must uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless the
/// host can prove their uniqueness across all callers: two distinct principals that share the same value
/// would receive the same isolation key and could read or overwrite one another's persisted sessions.
/// would receive the same isolation key and could read or overwrite one another's persisted data.
/// The default claim type is <see cref="ClaimTypes.NameIdentifier"/>, a stable unique subject identifier
/// that is typically populated from the OpenID Connect <c>sub</c> claim via the default JWT inbound claim
/// mapping (note that this differs from Entra's object identifier <c>oid</c> claim; override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> if you need <c>oid</c> or your
/// provider maps a different claim).
/// </para>
/// <para>
/// If the <see cref="HttpContext"/> is unavailable, the user is not authenticated, or the specified claim
/// is missing, the provider returns <see langword="null"/>. The consuming <see cref="IsolationKeyScopedAgentSessionStore"/>
/// will then enforce strict or pass-through behavior based on its configuration.
/// is missing, the provider returns <see langword="null"/>. Consuming stores then enforce strict or
/// pass-through behavior based on their configuration.
/// </para>
/// <para>
/// This class relies on <see cref="IHttpContextAccessor"/>, which uses <see cref="AsyncLocal{T}"/>
/// to provide access to the current <see cref="HttpContext"/>.
/// </para>
/// </remarks>
public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProvider
public class ClaimsIdentityAgentIsolationKeyProvider : AgentIsolationKeyProvider
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly string _claimType;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProvider"/> class.
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProvider"/> class.
/// </summary>
/// <param name="httpContextAccessor">
/// The <see cref="IHttpContextAccessor"/> used to retrieve the current HTTP context and user claims.
/// </param>
/// <param name="options">The options for configuring the provider. If null, defaults are used.</param>
/// <exception cref="ArgumentException">
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/> is null, empty, or whitespace.
/// </exception>
public ClaimsIdentitySessionIsolationKeyProvider(
public ClaimsIdentityAgentIsolationKeyProvider(
IHttpContextAccessor? httpContextAccessor,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
{
options ??= new ClaimsIdentitySessionIsolationKeyProviderOptions();
options ??= new ClaimsIdentityAgentIsolationKeyProviderOptions();
this._httpContextAccessor = httpContextAccessor;
this._claimType = Throw.IfNullOrWhitespace(options.ClaimType);
}
/// <summary>
/// Extracts the session isolation key from the current user's claims.
/// Extracts the isolation key for agent-owned resources from the current user's claims.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -81,7 +81,7 @@ public class ClaimsIdentitySessionIsolationKeyProvider : SessionIsolationKeyProv
/// unauthenticated identity. The claim value is retrieved from <c>HttpContext.User.Claims</c>; if
/// multiple claims of the specified type exist, the first match is returned.
/// </remarks>
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
{
ClaimsPrincipal? user = this._httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
@@ -5,12 +5,12 @@ using System.Security.Claims;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Options for configuring <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// Options for configuring <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderOptions
public class ClaimsIdentityAgentIsolationKeyProviderOptions
{
/// <summary>
/// Gets or sets the claim type to extract from the user's identity for session isolation.
/// Gets or sets the claim type to extract from the user's identity to isolate agent-owned resources.
/// </summary>
/// <remarks>
/// <para>
@@ -28,8 +28,8 @@ public class ClaimsIdentitySessionIsolationKeyProviderOptions
/// / <see cref="ClaimTypes.Name"/>), usernames, email aliases, and other mutable or non-unique
/// claims are <strong>unsafe</strong> isolation keys unless the host can prove their uniqueness
/// across all callers. Two distinct principals that share the same value for a non-unique claim
/// would receive the same session-isolation key and could read or overwrite one another's
/// persisted sessions. Only override this value with a claim that is guaranteed unique and stable.
/// would receive the same agent-isolation key and could read or overwrite one another's
/// persisted data. Only override this value with a claim that is guaranteed unique and stable.
/// </para>
/// <para>
/// Common alternatives include:
@@ -13,11 +13,11 @@ namespace Microsoft.Agents.AI.Hosting;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers a <see cref="SessionIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate session isolation keys.
/// Registers an <see cref="AgentIsolationKeyProvider"/> that uses claims from the current user's identity
/// to generate isolation keys for agent-owned resources.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <param name="options"> Optional configuration for the claims-based session isolation key provider.</param>
/// <param name="options">Optional configuration for the claims-based isolation key provider.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
/// <remarks>
/// <para>
@@ -31,24 +31,24 @@ public static class ServiceCollectionExtensions
/// by the default JWT inbound claim mapping. Authentication schemes that do not project a unique
/// identifier onto <see cref="ClaimTypes.NameIdentifier"/> (or hosts that require a different claim
/// such as Entra's <c>oid</c>) should override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode session stores to fail.
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>; otherwise the key may be
/// absent, which causes strict-mode stores to fail.
/// </para>
/// <para>
/// <strong>Security warning:</strong> If you override
/// <see cref="ClaimsIdentitySessionIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// <see cref="ClaimsIdentityAgentIsolationKeyProviderOptions.ClaimType"/>, the chosen claim must
/// uniquely identify the principal within the served population. Display names, usernames, email
/// aliases, and other mutable or non-unique claims are <strong>unsafe</strong> isolation keys unless
/// the host can prove their uniqueness across all callers, because distinct principals that share the
/// same claim value would receive the same isolation key and could access one another's sessions.
/// same claim value would receive the same isolation key and could access one another's persisted data.
/// </para>
/// </remarks>
public static IServiceCollection UseClaimsBasedSessionIsolation(
public static IServiceCollection UseClaimsBasedAgentIsolation(
this IServiceCollection services,
ClaimsIdentitySessionIsolationKeyProviderOptions? options = null)
ClaimsIdentityAgentIsolationKeyProviderOptions? options = null)
{
options ??= new();
ServiceDescriptor descriptor = new(typeof(SessionIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
ServiceDescriptor descriptor = new(typeof(AgentIsolationKeyProvider), CreateIsolationKeyProvider, ServiceLifetime.Singleton);
services.Add(descriptor);
return services;
@@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions
{
IHttpContextAccessor contextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
return new ClaimsIdentitySessionIsolationKeyProvider(contextAccessor, options);
return new ClaimsIdentityAgentIsolationKeyProvider(contextAccessor, options);
}
}
}
@@ -6,24 +6,26 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// Provides an abstract base class for resolving session isolation keys used to scope agent sessions.
/// Provides an abstract base class for resolving keys that isolate resources owned by hosted agents.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// The <c>Agent</c> prefix identifies the hosting API domain; it does not mean that agent instances
/// themselves are isolated. The returned key scopes agent-owned resources, such as sessions and A2A
/// tasks, to a logical partition (e.g., user ID, tenant ID, or composite key). Other agent resources,
/// such as memory or retrieval data, can use the same key when they require the same isolation boundary.
/// Derived classes implement the key resolution logic appropriate to their hosting environment.
/// </para>
/// <para>
/// When a key is unavailable or cannot be determined, implementations should return <see langword="null"/>.
/// The consuming session store can then enforce strict behavior (throwing an exception) or fall back
/// to unscoped storage based on its configuration.
/// Consuming stores can then enforce strict behavior (throwing an exception) or fall back to unscoped
/// storage based on their configuration.
/// </para>
/// </remarks>
public abstract class SessionIsolationKeyProvider
public abstract class AgentIsolationKeyProvider
{
/// <summary>
/// Asynchronously retrieves the session isolation key for the current request or execution context.
/// Asynchronously retrieves the isolation key for agent-owned resources in the current request or execution context.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>
@@ -35,5 +37,5 @@ public abstract class SessionIsolationKeyProvider
/// or environment variables). If the key cannot be determined, return <see langword="null"/> to allow
/// the caller to decide on strict vs. pass-through behavior.
/// </remarks>
public abstract ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default);
public abstract ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default);
}
@@ -27,10 +27,10 @@ namespace Microsoft.Agents.AI.Hosting;
/// who knows or guesses another caller's <c>sessionStoreId</c> can resume
/// that other caller's persisted thread. The framework provides
/// <see cref="IsolationKeyScopedAgentSessionStore"/> as a decorator that rewrites
/// <c>sessionStoreId</c> to include an isolation key resolved from a
/// <see cref="SessionIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentitySessionIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedSessionIsolation(...)</c>). When no provider is registered, the
/// <c>sessionStoreId</c> to include an isolation key resolved from an
/// <see cref="AgentIsolationKeyProvider"/> (for example, the ASP.NET Core
/// <c>ClaimsIdentityAgentIsolationKeyProvider</c> wired up via
/// <c>UseClaimsBasedAgentIsolation(...)</c>). 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.
/// </para>
@@ -59,7 +59,7 @@ public static class HostedAgentBuilderExtensions
if (withIsolation && store.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
var isolationKeyProvider = sp.GetService<SessionIsolationKeyProvider>();
var isolationKeyProvider = sp.GetService<AgentIsolationKeyProvider>();
// Best efforts options getting
IsolationKeyScopedAgentSessionStoreOptions? options = sp.GetService<IsolationKeyScopedAgentSessionStoreOptions>();
@@ -8,12 +8,12 @@ namespace Microsoft.Agents.AI.Hosting;
/// <summary>
/// A delegating <see cref="AgentSessionStore"/> that scopes session keys by an isolation key
/// provided by a <see cref="SessionIsolationKeyProvider"/>, ensuring that sessions are isolated
/// provided by an <see cref="AgentIsolationKeyProvider"/>, ensuring that sessions are isolated
/// per logical partition (e.g., user, tenant, or composite key).
/// </summary>
public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
{
private readonly SessionIsolationKeyProvider? _keyProvider;
private readonly AgentIsolationKeyProvider? _keyProvider;
private readonly bool _strict;
/// <summary>
@@ -21,7 +21,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </summary>
/// <param name="innerStore">The underlying <see cref="AgentSessionStore"/> to delegate to.</param>
/// <param name="keyProvider">
/// The <see cref="SessionIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// The <see cref="AgentIsolationKeyProvider"/> used to retrieve the isolation key for the current context.
/// </param>
/// <param name="options">The options for configuring the session store. If null, defaults are used.</param>
/// <exception cref="ArgumentNullException">
@@ -29,7 +29,7 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
/// </exception>
public IsolationKeyScopedAgentSessionStore(
AgentSessionStore innerStore,
SessionIsolationKeyProvider? keyProvider,
AgentIsolationKeyProvider? keyProvider,
IsolationKeyScopedAgentSessionStoreOptions? options = null)
: base(innerStore)
{
@@ -51,12 +51,12 @@ public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore
private async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken)
{
string? key = this._keyProvider != null
? await this._keyProvider.GetSessionIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false)
: null;
if (this._strict && key == null)
{
throw new InvalidOperationException("Session isolation key is required but was not provided by the configured SessionIsolationKeyProvider.");
throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider.");
}
return key;
@@ -13,7 +13,7 @@ public class IsolationKeyScopedAgentSessionStoreOptions
/// <remarks>
/// <para>
/// If <see langword="true"/> (default), the store will throw an <see cref="System.InvalidOperationException"/>
/// when <see cref="SessionIsolationKeyProvider.GetSessionIsolationKeyAsync"/> returns <see langword="null"/>.
/// when <see cref="AgentIsolationKeyProvider.GetIsolationKeyAsync"/> returns <see langword="null"/>.
/// </para>
/// <para>
/// If <see langword="false"/>, the conversation ID is passed through unmodified when the isolation key is absent,
@@ -32,9 +32,9 @@ namespace Microsoft.Agents.AI.Hosting;
/// or guesses another caller's identifier can resume that other caller's persisted
/// thread. Multi-user hosts must wrap this store in
/// <see cref="IsolationKeyScopedAgentSessionStore"/> (typically by calling
/// <c>UseClaimsBasedSessionIsolation(...)</c> from
/// <c>UseClaimsBasedAgentIsolation(...)</c> from
/// <c>Microsoft.Agents.AI.Hosting.AspNetCore</c> or by registering a custom
/// <see cref="SessionIsolationKeyProvider"/>) so that the conversation namespace is
/// <see cref="AgentIsolationKeyProvider"/>) so that the conversation namespace is
/// scoped per principal. See the trust-model remarks on
/// <see cref="AgentSessionStore"/> for the full background.
/// </para>
@@ -106,7 +106,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
}
/// <summary>
/// Verifies that when a SessionIsolationKeyProvider is registered, task operations
/// Verifies that when an AgentIsolationKeyProvider is registered, task operations
/// use scoped identifiers (DI wiring test).
/// </summary>
[Fact]
@@ -126,9 +126,9 @@ public sealed class A2AServerServiceCollectionExtensionsTests
.ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } });
services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
var mockKeyProvider = new Mock<SessionIsolationKeyProvider>();
var mockKeyProvider = new Mock<AgentIsolationKeyProvider>();
mockKeyProvider
.Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny<CancellationToken>()))
.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(IsolationKey);
services.AddSingleton(mockKeyProvider.Object);
@@ -163,9 +163,9 @@ public sealed class A2AServerServiceCollectionExtensionsTests
.Setup(s => s.GetTaskAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AgentTask { Id = TaskId, ContextId = $"{IsolationKey}::ctx-1", Status = new global::A2A.TaskStatus { State = TaskState.Completed } });
var mockKeyProvider = new Mock<SessionIsolationKeyProvider>();
var mockKeyProvider = new Mock<AgentIsolationKeyProvider>();
mockKeyProvider
.Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny<CancellationToken>()))
.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(IsolationKey);
// Pre-wrap the task store
@@ -325,10 +325,10 @@ public sealed class IsolationKeyScopedTaskStoreTests
Assert.Throws<ArgumentNullException>(() => new IsolationKeyScopedTaskStore(null!, null, strict: false));
}
private static SessionIsolationKeyProvider CreateKeyProvider(string? key)
private static AgentIsolationKeyProvider CreateKeyProvider(string? key)
{
var mock = new Mock<SessionIsolationKeyProvider>();
mock.Setup(p => p.GetSessionIsolationKeyAsync(It.IsAny<CancellationToken>()))
var mock = new Mock<AgentIsolationKeyProvider>();
mock.Setup(p => p.GetIsolationKeyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(key);
return mock.Object;
}
@@ -6,22 +6,22 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="SessionIsolationKeyProvider"/> and its contract.
/// Unit tests for <see cref="AgentIsolationKeyProvider"/> and its contract.
/// </summary>
public class SessionIsolationKeyProviderTests
public class AgentIsolationKeyProviderTests
{
/// <summary>
/// Verify that a concrete provider can return a non-null isolation key.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNonNullKeyAsync()
public async Task GetIsolationKeyAsyncReturnsNonNullKeyAsync()
{
// Arrange
const string ExpectedKey = "test-key";
var provider = new TestSessionIsolationKeyProvider(ExpectedKey);
var provider = new TestAgentIsolationKeyProvider(ExpectedKey);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal(ExpectedKey, result);
@@ -31,13 +31,13 @@ public class SessionIsolationKeyProviderTests
/// Verify that a concrete provider can return null when no key is available.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
public async Task GetIsolationKeyAsyncReturnsNullWhenNoKeyAvailableAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var provider = new TestAgentIsolationKeyProvider(null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -47,33 +47,33 @@ public class SessionIsolationKeyProviderTests
/// Verify that cancellation token is passed through to the provider implementation.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncPassesCancellationTokenAsync()
public async Task GetIsolationKeyAsyncPassesCancellationTokenAsync()
{
// Arrange
var provider = new TestCancellableSessionIsolationKeyProvider();
var provider = new TestCancellableAgentIsolationKeyProvider();
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<TaskCanceledException>(
async () => await provider.GetSessionIsolationKeyAsync(cts.Token));
async () => await provider.GetIsolationKeyAsync(cts.Token));
}
#region Test Implementations
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// Test implementation of <see cref="AgentIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
public TestAgentIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}
@@ -82,9 +82,9 @@ public class SessionIsolationKeyProviderTests
/// <summary>
/// Test implementation that respects cancellation tokens.
/// </summary>
private sealed class TestCancellableSessionIsolationKeyProvider : SessionIsolationKeyProvider
private sealed class TestCancellableAgentIsolationKeyProvider : AgentIsolationKeyProvider
{
public override async ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
public override async ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
{
await Task.Delay(1000, cancellationToken);
return "key";
@@ -9,9 +9,9 @@ using Moq;
namespace Microsoft.Agents.AI.Hosting.UnitTests;
/// <summary>
/// Unit tests for <see cref="ClaimsIdentitySessionIsolationKeyProvider"/>.
/// Unit tests for <see cref="ClaimsIdentityAgentIsolationKeyProvider"/>.
/// </summary>
public class ClaimsIdentitySessionIsolationKeyProviderTests
public class ClaimsIdentityAgentIsolationKeyProviderTests
{
private const string TestUserId = "test-user-id";
private const string CustomClaimType = "custom-claim-type";
@@ -21,9 +21,9 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsIdentitySessionIsolationKeyProviderTests"/> class.
/// Initializes a new instance of the <see cref="ClaimsIdentityAgentIsolationKeyProviderTests"/> class.
/// </summary>
public ClaimsIdentitySessionIsolationKeyProviderTests()
public ClaimsIdentityAgentIsolationKeyProviderTests()
{
this._httpContextAccessorMock = new Mock<IHttpContextAccessor>();
}
@@ -37,7 +37,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
public void UsesDefaultOptionsWhenNull()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object, options: null);
Assert.NotNull(provider);
}
@@ -48,7 +48,7 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
public void Constructor_WithNullHttpContextAccessor_DoesNotThrow()
{
// Act & Assert - should not throw
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(httpContextAccessor: null);
Assert.NotNull(provider);
}
@@ -60,9 +60,9 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentNullException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
new ClaimsIdentityAgentIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = null! }));
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = null! }));
}
/// <summary>
@@ -73,9 +73,9 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
new ClaimsIdentityAgentIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = string.Empty }));
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = string.Empty }));
}
/// <summary>
@@ -86,27 +86,27 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
{
// Act & Assert
Assert.Throws<ArgumentException>("options.ClaimType", () =>
new ClaimsIdentitySessionIsolationKeyProvider(
new ClaimsIdentityAgentIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = " " }));
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = " " }));
}
#endregion
#region GetSessionIsolationKeyAsync Tests
#region GetIsolationKeyAsync Tests
/// <summary>
/// Verify that GetSessionIsolationKeyAsync extracts the claim value from the default claim type.
/// Verify that GetIsolationKeyAsync extracts the claim value from the default claim type.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
public async Task GetIsolationKeyAsyncExtractsDefaultClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal(TestUserId, result);
@@ -114,54 +114,54 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
/// <summary>
/// Verify that the default claim type is the stable, unique NameIdentifier claim rather than the
/// non-unique display name claim. This guards against the session-isolation collision described in
/// non-unique display name claim. This guards against the resource-isolation collision described in
/// the security report where two principals sharing the same name claim received the same key.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
public async Task GetIsolationKeyAsyncIgnoresNameClaimByDefaultAsync()
{
// Arrange - only a display-name claim is present; the default provider must not use it.
this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync uses custom claim type when specified.
/// Verify that GetIsolationKeyAsync uses custom claim type when specified.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncUsesCustomClaimTypeAsync()
public async Task GetIsolationKeyAsyncUsesCustomClaimTypeAsync()
{
// Arrange
this.SetupHttpContextWithClaim(CustomClaimType, CustomClaimValue);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(
var provider = new ClaimsIdentityAgentIsolationKeyProvider(
this._httpContextAccessorMock.Object,
new ClaimsIdentitySessionIsolationKeyProviderOptions { ClaimType = CustomClaimType });
new ClaimsIdentityAgentIsolationKeyProviderOptions { ClaimType = CustomClaimType });
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal(CustomClaimValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns null when the specified claim is missing.
/// Verify that GetIsolationKeyAsync returns null when the specified claim is missing.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
public async Task GetIsolationKeyAsyncReturnsNullWhenClaimMissingAsync()
{
// Arrange
this.SetupHttpContextWithClaim("other-claim", "value");
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -171,14 +171,14 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
/// Verify behavior when HttpContextAccessor returns null HttpContext.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
public async Task GetIsolationKeyAsyncReturnsNullWhenHttpContextNullAsync()
{
// Arrange
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns((HttpContext?)null);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Null(result);
@@ -188,23 +188,23 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
/// Verify behavior when HttpContextAccessor itself is null.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
public async Task GetIsolationKeyAsyncReturnsNullWhenHttpContextAccessorNullAsync()
{
// Arrange
var provider = new ClaimsIdentitySessionIsolationKeyProvider(httpContextAccessor: null);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(httpContextAccessor: null);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Null(result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns the first matching claim when multiple exist.
/// Verify that GetIsolationKeyAsync returns the first matching claim when multiple exist.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
public async Task GetIsolationKeyAsyncReturnsFirstMatchingClaimAsync()
{
// Arrange
const string FirstValue = "first-value";
@@ -223,39 +223,39 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
};
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal(FirstValue, result);
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync handles empty claim values.
/// Verify that GetIsolationKeyAsync handles empty claim values.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncHandlesEmptyClaimValueAsync()
public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync()
{
// Arrange
this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal(string.Empty, result);
}
/// <summary>
/// Regression test for the session-isolation collision security report: two distinct authenticated
/// Regression test for the resource-isolation collision security report: two distinct authenticated
/// principals that share the same display-name claim but have different stable identifiers and tenants
/// must produce distinct isolation keys under the default options.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
public async Task GetIsolationKeyAsyncDistinctForPrincipalsSharingNameClaimAsync()
{
// Arrange - both principals share the same name claim but differ by NameIdentifier and tenant.
const string CommonName = "John Doe";
@@ -270,14 +270,14 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
new Claim(ClaimTypes.NameIdentifier, "oid-user-b"),
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", "tenant-b"));
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalA });
string? principalAKey = await provider.GetSessionIsolationKeyAsync();
string? principalAKey = await provider.GetIsolationKeyAsync();
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(new DefaultHttpContext { User = principalB });
string? principalBKey = await provider.GetSessionIsolationKeyAsync();
string? principalBKey = await provider.GetIsolationKeyAsync();
// Assert
Assert.Equal("oid-user-a", principalAKey);
@@ -286,12 +286,12 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
}
/// <summary>
/// Verify that GetSessionIsolationKeyAsync returns null when the request's user is not authenticated,
/// Verify that GetIsolationKeyAsync returns null when the request's user is not authenticated,
/// even if a claim of the configured type is present. The provider must not derive an isolation key
/// from claims on an unauthenticated identity.
/// </summary>
[Fact]
public async Task GetSessionIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
public async Task GetIsolationKeyAsyncReturnsNullWhenUserNotAuthenticatedAsync()
{
// Arrange - identity has the claim but no authentication type, so IsAuthenticated is false.
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, TestUserId) };
@@ -299,10 +299,10 @@ public class ClaimsIdentitySessionIsolationKeyProviderTests
var principal = new ClaimsPrincipal(unauthenticatedIdentity);
var httpContext = new DefaultHttpContext { User = principal };
this._httpContextAccessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var provider = new ClaimsIdentitySessionIsolationKeyProvider(this._httpContextAccessorMock.Object);
var provider = new ClaimsIdentityAgentIsolationKeyProvider(this._httpContextAccessorMock.Object);
// Act
string? result = await provider.GetSessionIsolationKeyAsync();
string? result = await provider.GetIsolationKeyAsync();
// Assert
Assert.False(unauthenticatedIdentity.IsAuthenticated);
@@ -46,7 +46,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void RequiresInnerStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
// Act & Assert
Assert.Throws<ArgumentNullException>("innerStore", () =>
@@ -60,7 +60,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void UsesDefaultOptionsWhenNull()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
// Act & Assert - should not throw
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null);
@@ -78,7 +78,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -100,7 +100,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var provider = new TestAgentIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -110,7 +110,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId));
Assert.Contains("Session isolation key is required", exception.Message);
Assert.Contains("Agent isolation key is required", exception.Message);
}
/// <summary>
@@ -120,7 +120,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var provider = new TestAgentIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -145,7 +145,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -166,7 +166,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
var sessionToSave = new TestAgentSession();
@@ -190,7 +190,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var provider = new TestAgentIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -201,7 +201,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave));
Assert.Contains("Session isolation key is required", exception.Message);
Assert.Contains("Agent isolation key is required", exception.Message);
}
/// <summary>
@@ -211,7 +211,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(null);
var provider = new TestAgentIsolationKeyProvider(null);
var store = new IsolationKeyScopedAgentSessionStore(
this._innerStoreMock.Object,
provider,
@@ -243,7 +243,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithColon = "key:with:colons";
var provider = new TestSessionIsolationKeyProvider(KeyWithColon);
var provider = new TestAgentIsolationKeyProvider(KeyWithColon);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -266,7 +266,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithBackslash = @"domain\key";
var provider = new TestSessionIsolationKeyProvider(KeyWithBackslash);
var provider = new TestAgentIsolationKeyProvider(KeyWithBackslash);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -289,7 +289,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
const string KeyWithBoth = @"domain\key:role";
var provider = new TestSessionIsolationKeyProvider(KeyWithBoth);
var provider = new TestAgentIsolationKeyProvider(KeyWithBoth);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -336,12 +336,12 @@ public class IsolationKeyScopedAgentSessionStoreTests
.ReturnsAsync(this._testSession);
// Act - Key 1
var provider1 = new TestSessionIsolationKeyProvider(Key1);
var provider1 = new TestAgentIsolationKeyProvider(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 provider2 = new TestAgentIsolationKeyProvider(Key2);
var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2);
await store2.GetSessionAsync(this._agentMock.Object, TestConversationId);
@@ -362,7 +362,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
public void GetServiceReturnsIsolationKeyScopedAgentSessionStore()
{
// Arrange
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider);
// Act
@@ -380,7 +380,7 @@ public class IsolationKeyScopedAgentSessionStoreTests
{
// Arrange
var concreteInnerStore = new ConcreteAgentSessionStore();
var provider = new TestSessionIsolationKeyProvider(TestIsolationKey);
var provider = new TestAgentIsolationKeyProvider(TestIsolationKey);
var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider);
// Act
@@ -395,18 +395,18 @@ public class IsolationKeyScopedAgentSessionStoreTests
#region Helper Classes
/// <summary>
/// Test implementation of <see cref="SessionIsolationKeyProvider"/> for testing purposes.
/// Test implementation of <see cref="AgentIsolationKeyProvider"/> for testing purposes.
/// </summary>
private sealed class TestSessionIsolationKeyProvider : SessionIsolationKeyProvider
private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider
{
private readonly string? _key;
public TestSessionIsolationKeyProvider(string? key)
public TestAgentIsolationKeyProvider(string? key)
{
this._key = key;
}
public override ValueTask<string?> GetSessionIsolationKeyAsync(CancellationToken cancellationToken = default)
public override ValueTask<string?> GetIsolationKeyAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<string?>(this._key);
}