From 96601cb36a77fa4d2793a22b5b1149ce863b9d68 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:21:21 +0000 Subject: [PATCH 1/3] Switch auth sample to use Singletons --- .../Service/Program.cs | 6 +- .../Service/UserContext.cs | 78 ++++++++++++------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs index b4a5d00a9a..38b70a142f 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs @@ -76,9 +76,9 @@ string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini"; builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(sp => +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => { var expenseService = sp.GetRequiredService(); diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs index 34f4fe8956..bc0bbafc1d 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs @@ -27,43 +27,67 @@ public interface IUserContext /// Keycloak uses sub for the user ID, preferred_username /// for the login name, given_name/family_name for the /// display name, and scope (space-delimited) for granted scopes. -/// Registered as a scoped service so it is resolved once per request. +/// Registered as a singleton — properties are read from the current +/// on every access. /// public sealed class KeycloakUserContext : IUserContext { - public string UserId { get; } - - public string UserName { get; } - - public string DisplayName { get; } - - public IReadOnlySet Scopes { get; } + private readonly IHttpContextAccessor _httpContextAccessor; public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) { - ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User; + this._httpContextAccessor = httpContextAccessor; + } - this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? user?.FindFirstValue("sub") - ?? "anonymous"; + public string UserId + { + get + { + ClaimsPrincipal? user = this.CurrentUser; + return user?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user?.FindFirstValue("sub") + ?? "anonymous"; + } + } - this.UserName = user?.FindFirstValue("preferred_username") - ?? user?.FindFirstValue(ClaimTypes.Name) - ?? "unknown"; + public string UserName + { + get + { + ClaimsPrincipal? user = this.CurrentUser; + return user?.FindFirstValue("preferred_username") + ?? user?.FindFirstValue(ClaimTypes.Name) + ?? "unknown"; + } + } - string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName); - string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname); - this.DisplayName = (givenName, familyName) switch + public string DisplayName + { + get { - (not null, not null) => $"{givenName} {familyName}", - (not null, null) => givenName, - (null, not null) => familyName, - _ => this.UserName, - }; + ClaimsPrincipal? user = this.CurrentUser; + string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName); + string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname); + return (givenName, familyName) switch + { + (not null, not null) => $"{givenName} {familyName}", + (not null, null) => givenName, + (null, not null) => familyName, + _ => this.UserName, + }; + } + } - string? scopeClaim = user?.FindFirstValue("scope"); - this.Scopes = scopeClaim is not null - ? new HashSet(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase) - : new HashSet(StringComparer.OrdinalIgnoreCase); + public IReadOnlySet Scopes + { + get + { + string? scopeClaim = this.CurrentUser?.FindFirstValue("scope"); + return scopeClaim is not null + ? new HashSet(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + } } + + private ClaimsPrincipal? CurrentUser => this._httpContextAccessor.HttpContext?.User; } From 5262d3eb38e4c17b668a82fce490297e144b26de Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:26:09 +0000 Subject: [PATCH 2/3] Address PR comments --- .../Service/UserContext.cs | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs index bc0bbafc1d..3c621f0207 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/UserContext.cs @@ -27,11 +27,13 @@ public interface IUserContext /// Keycloak uses sub for the user ID, preferred_username /// for the login name, given_name/family_name for the /// display name, and scope (space-delimited) for granted scopes. -/// Registered as a singleton — properties are read from the current -/// on every access. +/// Registered as a singleton — claims are parsed once per request and +/// cached in . /// public sealed class KeycloakUserContext : IUserContext { + private static readonly object s_cacheKey = new(); + private readonly IHttpContextAccessor _httpContextAccessor; public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) @@ -39,55 +41,59 @@ public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) this._httpContextAccessor = httpContextAccessor; } - public string UserId - { - get - { - ClaimsPrincipal? user = this.CurrentUser; - return user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? user?.FindFirstValue("sub") - ?? "anonymous"; - } - } + public string UserId => this.GetOrCreateCachedInfo().UserId; - public string UserName + public string UserName => this.GetOrCreateCachedInfo().UserName; + + public string DisplayName => this.GetOrCreateCachedInfo().DisplayName; + + public IReadOnlySet Scopes => this.GetOrCreateCachedInfo().Scopes; + + private CachedUserInfo GetOrCreateCachedInfo() { - get + HttpContext? httpContext = this._httpContextAccessor.HttpContext; + if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info) { - ClaimsPrincipal? user = this.CurrentUser; - return user?.FindFirstValue("preferred_username") - ?? user?.FindFirstValue(ClaimTypes.Name) - ?? "unknown"; + return info; } - } - public string DisplayName - { - get + info = ParseClaims(httpContext?.User); + + if (httpContext is not null) { - ClaimsPrincipal? user = this.CurrentUser; - string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName); - string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname); - return (givenName, familyName) switch - { - (not null, not null) => $"{givenName} {familyName}", - (not null, null) => givenName, - (null, not null) => familyName, - _ => this.UserName, - }; + httpContext.Items[s_cacheKey] = info; } + + return info; } - public IReadOnlySet Scopes + private static CachedUserInfo ParseClaims(ClaimsPrincipal? user) { - get + string userId = user?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user?.FindFirstValue("sub") + ?? "anonymous"; + + string userName = user?.FindFirstValue("preferred_username") + ?? user?.FindFirstValue(ClaimTypes.Name) + ?? "unknown"; + + string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName); + string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname); + string displayName = (givenName, familyName) switch { - string? scopeClaim = this.CurrentUser?.FindFirstValue("scope"); - return scopeClaim is not null - ? new HashSet(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase) - : new HashSet(StringComparer.OrdinalIgnoreCase); - } + (not null, not null) => $"{givenName} {familyName}", + (not null, null) => givenName, + (null, not null) => familyName, + _ => userName, + }; + + string? scopeClaim = user?.FindFirstValue("scope"); + IReadOnlySet scopes = scopeClaim is not null + ? new HashSet(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + return new CachedUserInfo(userId, userName, displayName, scopes); } - private ClaimsPrincipal? CurrentUser => this._httpContextAccessor.HttpContext?.User; + private sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet Scopes); } From 66ed58865b5b6e381d94fd59c1d8b6c10a4ecea0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:03:54 +0000 Subject: [PATCH 3/3] Add comment to warn users to choose the appropriate lifetime for their service --- .../AspNetAgentAuthorization/Service/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs index 38b70a142f..1d89296a2e 100644 --- a/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs +++ b/dotnet/samples/05-end-to-end/AspNetAgentAuthorization/Service/Program.cs @@ -75,6 +75,11 @@ ?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable."); string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini"; +// Here we are using Singleton lifetime, since none of the services, function tools and user context classes in the sample have state that are per request. +// You should evaluate the appropriate lifetime for your own services and tools based on their behavior and dependencies. +// E.g. if any of the service instances or tools maintain state that is specific to a user, and each request may be from a different user, +// you should use Scoped lifetime instead, so that a new instance is created for each request. +// Note that if you use Scoped lifetime for any dependencies, you must also use Scoped lifetime for any class that uses it, including the agent itself. builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); builder.Services.AddSingleton();