Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -76,9 +76,9 @@
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<AIAgent>(sp =>
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
Comment thread
westey-m marked this conversation as resolved.
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