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..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,10 +75,15 @@ ?? 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.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..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,43 +27,73 @@ 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 — claims are parsed once per request and +/// cached in . /// public sealed class KeycloakUserContext : IUserContext { - public string UserId { get; } + private static readonly object s_cacheKey = new(); - public string UserName { get; } + private readonly IHttpContextAccessor _httpContextAccessor; - public string DisplayName { get; } + public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) + { + this._httpContextAccessor = httpContextAccessor; + } - public IReadOnlySet Scopes { get; } + public string UserId => this.GetOrCreateCachedInfo().UserId; - public KeycloakUserContext(IHttpContextAccessor httpContextAccessor) + public string UserName => this.GetOrCreateCachedInfo().UserName; + + public string DisplayName => this.GetOrCreateCachedInfo().DisplayName; + + public IReadOnlySet Scopes => this.GetOrCreateCachedInfo().Scopes; + + private CachedUserInfo GetOrCreateCachedInfo() { - ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User; + HttpContext? httpContext = this._httpContextAccessor.HttpContext; + if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info) + { + return info; + } - this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? user?.FindFirstValue("sub") - ?? "anonymous"; + info = ParseClaims(httpContext?.User); - this.UserName = user?.FindFirstValue("preferred_username") - ?? user?.FindFirstValue(ClaimTypes.Name) - ?? "unknown"; + if (httpContext is not null) + { + httpContext.Items[s_cacheKey] = info; + } + + return info; + } + + private static CachedUserInfo ParseClaims(ClaimsPrincipal? user) + { + 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); - this.DisplayName = (givenName, familyName) switch + string displayName = (givenName, familyName) switch { (not null, not null) => $"{givenName} {familyName}", (not null, null) => givenName, (null, not null) => familyName, - _ => this.UserName, + _ => userName, }; string? scopeClaim = user?.FindFirstValue("scope"); - this.Scopes = scopeClaim is not null + 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 sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet Scopes); }