diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs index c1d6136239..8d9416f002 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStore.cs @@ -32,21 +32,17 @@ public class UserIdentityScopedSessionStore : DelegatingAgentSessionStore /// /// The used to retrieve the current user's claims. /// - /// - /// The claim type to extract from the user's identity for scoping. Defaults to . - /// - /// - /// If , an exception is thrown when the specified claim is not found. - /// If , the conversation ID is passed through unmodified when the claim is absent. - /// - public UserIdentityScopedSessionStore(AgentSessionStore innerStore, - IHttpContextAccessor? contextAccessor, - string claimType = ClaimsIdentity.DefaultNameClaimType, - bool strict = true) : base(innerStore) + /// The options for configuring the session store. If null, defaults are used. + public UserIdentityScopedSessionStore( + AgentSessionStore innerStore, + IHttpContextAccessor? contextAccessor, + UserIdentityScopedSessionStoreOptions? options = null) : base(innerStore) { + options ??= new UserIdentityScopedSessionStoreOptions(); + this._httpContextAccessor = contextAccessor; - this._claimType = Throw.IfNullOrWhitespace(claimType); - this._strict = strict; + this._claimType = Throw.IfNullOrWhitespace(options.ClaimType); + this._strict = options.Strict; } private string? GetScopeFromIdentity() diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs new file mode 100644 index 0000000000..40c8b08b1b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/UserIdentityScopedSessionStoreOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Security.Claims; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Options for configuring . +/// +public class UserIdentityScopedSessionStoreOptions +{ + /// + /// Gets or sets the claim type to extract from the user's identity for scoping. + /// + /// + /// Defaults to . + /// + public string ClaimType { get; set; } = ClaimsIdentity.DefaultNameClaimType; + + /// + /// Gets or sets a value indicating whether an exception should be thrown when the specified claim is not found. + /// + /// + /// If , an exception is thrown when the specified claim is not found. + /// If , the conversation ID is passed through unmodified when the claim is absent. + /// Defaults to . + /// + public bool Strict { get; set; } = true; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs index a9c3d436df..6e8314a74c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// implements the decorator pattern for s, -/// enabling the creation of pipeliens where each layer can add functionality while delegating core operations to an +/// enabling the creation of pipelines where each layer can add functionality while delegating core operations to an /// underlying store. /// /// @@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Hosting; /// interface. /// /// -public class DelegatingAgentSessionStore : AgentSessionStore +public abstract class DelegatingAgentSessionStore : AgentSessionStore { /// /// Initializes a new instance of the class with the specified inner diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs index 504c4177f7..3098ad9021 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/UserIdentityScopedSessionStoreTests.cs @@ -52,7 +52,23 @@ public class UserIdentityScopedSessionStoreTests /// [Fact] public void RequiresInnerStore() => - Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object)); + Assert.Throws("innerStore", () => new UserIdentityScopedSessionStore(null!, this._httpContextAccessorMock.Object, CreateOptions())); + + /// + /// Verify that constructor uses default options when options is null. + /// + [Fact] + public void UsesDefaultOptionsWhenNull() + { + // Arrange + this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); + + // Act - should not throw and use default claim type + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, options: null); + + // Assert + Assert.NotNull(store); + } /// /// Verify that constructor accepts null IHttpContextAccessor. @@ -61,7 +77,7 @@ public class UserIdentityScopedSessionStoreTests public void Constructor_WithNullHttpContextAccessor_DoesNotThrow() { // Act & Assert - should not throw - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, strict: false); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, contextAccessor: null, CreateOptions(strict: false)); Assert.NotNull(store); } @@ -70,21 +86,21 @@ public class UserIdentityScopedSessionStoreTests /// [Fact] public void RequiresClaimType_NotNull() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: null!)); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: null!))); /// /// Verify that constructor throws ArgumentException when claimType is empty. /// [Fact] public void RequiresClaimType_NotEmpty() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: string.Empty)); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: string.Empty))); /// /// Verify that constructor throws ArgumentException when claimType is whitespace. /// [Fact] public void RequiresClaimType_NotWhitespace() => - Assert.Throws("claimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, claimType: " ")); + Assert.Throws("options.ClaimType", () => new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions(claimType: " "))); #endregion @@ -98,7 +114,7 @@ public class UserIdentityScopedSessionStoreTests { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -123,7 +139,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - claimType: CustomClaimType); + CreateOptions(claimType: CustomClaimType)); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -148,7 +164,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -168,7 +184,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -190,7 +206,7 @@ public class UserIdentityScopedSessionStoreTests { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -211,7 +227,7 @@ public class UserIdentityScopedSessionStoreTests { // Arrange this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, TestUserId); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); var sessionToSave = new TestAgentSession(); // Act @@ -238,7 +254,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - claimType: CustomClaimType); + CreateOptions(claimType: CustomClaimType)); var sessionToSave = new TestAgentSession(); // Act @@ -265,7 +281,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); var sessionToSave = new TestAgentSession(); // Act & Assert @@ -286,7 +302,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); var sessionToSave = new TestAgentSession(); // Act - should not throw @@ -317,7 +333,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: true); + CreateOptions(strict: true)); // Act & Assert await Assert.ThrowsAsync( @@ -335,7 +351,7 @@ public class UserIdentityScopedSessionStoreTests var store = new UserIdentityScopedSessionStore( this._innerStoreMock.Object, this._httpContextAccessorMock.Object, - strict: false); + CreateOptions(strict: false)); // Act - should not throw await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -376,12 +392,12 @@ public class UserIdentityScopedSessionStoreTests // Act - User 1 this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User1); - var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store1 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); // Act - User 2 this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, User2); - var store2 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store2 = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); // Assert @@ -399,7 +415,7 @@ public class UserIdentityScopedSessionStoreTests // Arrange const string UserIdWithColon = "user:with:colons"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithColon); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -422,7 +438,7 @@ public class UserIdentityScopedSessionStoreTests // Arrange const string UserIdWithBackslash = @"domain\user"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBackslash); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -445,7 +461,7 @@ public class UserIdentityScopedSessionStoreTests // Arrange const string UserIdWithBoth = @"domain\user:role"; this.SetupHttpContextWithClaim(ClaimsIdentity.DefaultNameClaimType, UserIdWithBoth); - var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object); + var store = new UserIdentityScopedSessionStore(this._innerStoreMock.Object, this._httpContextAccessorMock.Object, CreateOptions()); // Act await store.GetSessionAsync(this._agentMock.Object, TestConversationId); @@ -463,6 +479,17 @@ public class UserIdentityScopedSessionStoreTests #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) };