mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Switch auth sample to use Singletons (#4454)
* Switch auth sample to use Singletons * Address PR comments * Add comment to warn users to choose the appropriate lifetime for their service
This commit is contained in:
@@ -75,10 +75,15 @@ string apiKey = builder.Configuration["OPENAI_API_KEY"]
|
||||
?? 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<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<AIAgent>(sp =>
|
||||
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
|
||||
builder.Services.AddSingleton<ExpenseService>();
|
||||
builder.Services.AddSingleton<AIAgent>(sp =>
|
||||
{
|
||||
var expenseService = sp.GetRequiredService<ExpenseService>();
|
||||
|
||||
|
||||
@@ -27,43 +27,73 @@ public interface IUserContext
|
||||
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
|
||||
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
|
||||
/// display name, and <c>scope</c> (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 <see cref="HttpContext.Items"/>.
|
||||
/// </summary>
|
||||
public sealed class KeycloakUserContext : IUserContext
|
||||
{
|
||||
public string UserId { get; }
|
||||
private static readonly object s_cacheKey = new();
|
||||
|
||||
public string UserName { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public IReadOnlySet<string> 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 => this.GetOrCreateCachedInfo().UserId;
|
||||
|
||||
this.UserName = user?.FindFirstValue("preferred_username")
|
||||
?? user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? "unknown";
|
||||
public string UserName => this.GetOrCreateCachedInfo().UserName;
|
||||
|
||||
public string DisplayName => this.GetOrCreateCachedInfo().DisplayName;
|
||||
|
||||
public IReadOnlySet<string> Scopes => this.GetOrCreateCachedInfo().Scopes;
|
||||
|
||||
private CachedUserInfo GetOrCreateCachedInfo()
|
||||
{
|
||||
HttpContext? httpContext = this._httpContextAccessor.HttpContext;
|
||||
if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
info = ParseClaims(httpContext?.User);
|
||||
|
||||
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<string> scopes = scopeClaim is not null
|
||||
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new CachedUserInfo(userId, userName, displayName, scopes);
|
||||
}
|
||||
|
||||
private sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet<string> Scopes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user