Current user
ICurrentUser gives handlers transport-neutral access to the authenticated user, without depending on HttpContext.
Handlers often need to know who is calling — for authorization, auditing, or
user-scoped caching. Elarion exposes this through a transport-neutral
ICurrentUser abstraction so application code never depends on HttpContext or
IHttpContextAccessor.
This follows the framework's boundary rule: Elarion.Abstractions owns the abstraction, Elarion owns
the transport-neutral claims-based default, and Elarion.AspNetCore owns the HTTP integration that fills
it in.
The abstraction
namespace Elarion.Abstractions.Identity;
public interface ICurrentUser {
string UserId { get; }
string? Email { get; }
IReadOnlyList<string> Roles { get; }
bool IsAuthenticated { get; }
bool IsInRole(string role);
bool HasClaim(string type, string value);
IEnumerable<string> GetClaimValues(string type);
}Inject it like any other service:
[Service]
public sealed class OwnershipStamp(ICurrentUser user) {
// Stamp the signed-in account onto a new row, so reads can be scoped per user.
public string CurrentOwnerId => user.UserId;
}Wiring it in an ASP.NET Core host
The default implementation reads claims from the authenticated principal into a scoped snapshot. Register it during service configuration and add the middleware after authentication:
using Elarion.AspNetCore.Identity; // AddElarionCurrentUser / UseElarionCurrentUser
builder.Services.AddElarionCurrentUser();
var app = builder.Build();
app.UseAuthentication();
app.UseElarionCurrentUser(); // after authentication, before endpoints
app.UseAuthorization();UseElarionCurrentUser() seeds the scoped ClaimsPrincipalCurrentUser with the request principal once
per request.
Application handlers then read the snapshot, so they never touch HttpContext and stay testable —
in a unit test you simply register a fake ICurrentUser.
Works across every transport
ICurrentUser resolves identically under plain HTTP endpoints, JSON-RPC (single and batch), and MCP —
no extra wiring. This matters because the JSON-RPC and MCP dispatchers run each call in a fresh DI
child scope for isolation, and a child scope does not inherit the request scope's scoped instances. So
the snapshot the request middleware initialized is not the one a JSON-RPC/MCP handler would otherwise
resolve.
Elarion bridges that gap explicitly (no IHttpContextAccessor, no AsyncLocal) with one uniform path:
each transport captures the authenticated principal at its boundary into a DispatchScopeContext, and a
single initializer seeds the per-call snapshot from it. JSON-RPC captures HttpContext.User; MCP captures
the per-message RequestContext.User (its per-call scope is rooted at the session/app root, so it can't
reach the HTTP request scope anyway). HTTP [HttpEndpoint] handlers run in the request scope and read the
snapshot the middleware seeds — the same Initialize(principal) operation. The snapshot materializes
claims lazily, so seeding it per call costs nothing until a handler reads a claim, and each snapshot
parses at most once — no duplicate work. Authorization rides the same path, since the authorizer reads
ICurrentUser.
Carrying your own scoped state
The same rail is the extension point for other per-call state (tenant, correlation id, …). Capture it at
the boundary and seed it with an IDispatchScopeInitializer, registered with TryAddEnumerable. (For
tenant isolation of EF Core data specifically, prefer the built-in
ambient tenant scoping, whose ClaimsTenantResolver already reads the
tenant claim from ICurrentUser.)
internal sealed class TenantScopeInitializer : IDispatchScopeInitializer {
public void Initialize(IServiceProvider callScope, DispatchScopeContext context) {
if (context.TryGet<ClaimsPrincipal>(out var user) && user is not null) {
callScope.GetRequiredService<TenantContext>().TenantId = user.FindFirstValue("tenant");
}
}
}
// services.TryAddEnumerable(
// ServiceDescriptor.Singleton<IDispatchScopeInitializer, TenantScopeInitializer>());Mapping claims
By default the middleware maps standard claim types. Override them when your identity provider uses different ones:
builder.Services.AddElarionCurrentUser(options => {
options.UserIdClaimType = "sub";
options.EmailClaimType = "email";
options.RoleClaimType = ClaimTypes.Role;
options.DefaultRolesWhenAuthenticated = ["user"];
});| Option | Default | Purpose |
|---|---|---|
UserIdClaimType | "sub" | Claim used for UserId. |
EmailClaimType | "email" | Claim used for Email. |
RoleClaimType | ClaimTypes.Role | Claim used to populate Roles. |
DefaultRolesWhenAuthenticated | empty | Roles granted to any authenticated principal. |
Behind an authenticating reverse proxy, proxy identity registers this mapping from its own claim-name settings.
One mapping per host
A host has exactly one mapping, and the first registration wins: AddElarionCurrentUser (and with it
AddElarionClaimsCurrentUser, AddElarionIdentity and AddElarionProxyIdentity, which call it) registers the
ClaimsCurrentUserOptions singleton with TryAdd, so a later call's mapping is ignored without an error.
When the application builds or replaces the principal itself — a local account's principal from ASP.NET Core
Identity, a hand-built one — build it under the claim types of the mapping in effect rather than registering a second
mapping. ClaimsCurrentUserOptions is resolvable from DI for exactly that; with Identity, point
IdentityOptions.ClaimsIdentity at it. Replacing the principal
shows the recipe.
Relationship to caching
CurrentUser-scoped caching depends on this abstraction: the cache uses
ICurrentUser.UserId (hashed) to isolate entries per user. If you use CurrentUser scope, make sure
AddElarionCurrentUser() and UseElarionCurrentUser() are wired and the principal carries a user id
claim.
Writing a custom transport
Invoke Elarion handlers from gRPC, a console, a queue, or another host while preserving scoped identity and the full handler pipeline.
ASP.NET Core Identity
Optional ASP.NET Core Identity integration that composes onto a plain DbContext — no IdentityDbContext inheritance, snake_case-ready.