Elarion

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"];
});
OptionDefaultPurpose
UserIdClaimType"sub"Claim used for UserId.
EmailClaimType"email"Claim used for Email.
RoleClaimTypeClaimTypes.RoleClaim used to populate Roles.
DefaultRolesWhenAuthenticatedemptyRoles 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.

On this page