Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<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>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
private readonly IHttpContextAccessor _httpContextAccessor;

public string DisplayName { get; }
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
{
this._httpContextAccessor = httpContextAccessor;
}

public IReadOnlySet<string> 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<string> 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<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);
}
Loading