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 owns the abstraction; 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() copies claim values into a scoped CurrentUserSnapshot 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:

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.

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