MesAuth.Authorizer 10.23.0

dotnet add package MesAuth.Authorizer --version 10.23.0
                    
NuGet\Install-Package MesAuth.Authorizer -Version 10.23.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MesAuth.Authorizer" Version="10.23.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MesAuth.Authorizer" Version="10.23.0" />
                    
Directory.Packages.props
<PackageReference Include="MesAuth.Authorizer" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MesAuth.Authorizer --version 10.23.0
                    
#r "nuget: MesAuth.Authorizer, 10.23.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MesAuth.Authorizer@10.23.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MesAuth.Authorizer&version=10.23.0
                    
Install as a Cake Addin
#tool nuget:?package=MesAuth.Authorizer&version=10.23.0
                    
Install as a Cake Tool

MesAuth.Authorizer — Integration Guide

Package: MesAuth.Authorizer · Current version: 10.21.0
Targets: .NET 8 / .NET 9 / .NET 10
Audience: .NET developer adding auth to a new consumer API or troubleshooting an existing integration


Table of Contents

  1. Quick-start checklist
  2. Basic auth setup
  3. AI schema discovery
  4. Cluster / shared cache (Main.Api)
  5. IClientCacheService — full reference
  6. Concurrency primitives — semaphores & work queues
  7. appsettings.json reference
  8. Program.cs reference
  9. HttpContext extension methods
  10. Common pitfalls

1. Quick-start checklist

  • Add MesAuth.Authorizer NuGet reference
  • Set AppId, AppKey, Issuer, WellknowConfigUri in appsettings.json
  • Call await builder.Services.AddMesAuthAsync(opt => { ... }) in Program.cs
  • Call app.UseCors() before app.UseMesAuth() — order matters
  • Confirm AutoRegisterEndpoints = true if you want endpoints auto-published to MesAuth

2. Basic auth setup

2.1 Service registration

await builder.Services.AddMesAuthAsync(opt =>
{
    opt.AppId  = builder.Configuration["MesAuth:AppId"]!;
    opt.AppKey = builder.Configuration["MesAuth:AppKey"]!;
    opt.WellknowConfigUri = builder.Configuration["MesAuth:WellknowConfigUri"]!;
    opt.Issuer = builder.Configuration["MesAuth:Issuer"]!;
});

AddMesAuthAsync is an async method — you must await it. It fetches the OpenID Connect discovery document at startup to obtain JWKS, audience, and endpoint URLs. If the auth server is unreachable at startup the app still starts, logging a warning; auth auto-heals on the first request once the server is back.

2.2 Middleware order

app.UseRouting();   // must be first
app.UseCors();      // must be before UseMesAuth
app.UseMesAuth();   // = UseAuthentication + UseAuthorization + PermMiddleware
app.MapControllers();

UseMesAuth() registers three things in order:

  1. A response header hook that appends Access-Control-Expose-Headers: X-MA-PERM, MesAuth before each response (needed for CORS)
  2. UseAuthentication() + UseAuthorization()
  3. UseMiddleware<PermMiddleware>() — fetches and evaluates per-request permissions

If you need to register middleware between UseAuthorization and PermMiddleware, call each separately instead of UseMesAuth().

2.3 Authentication schemes

Scheme constant Value Used for
AuthenServiceExtension.JwtScheme "Bearer" Browser/SPA users (cookie or header)
AuthenServiceExtension.AppKeyScheme "AppKey" Service-to-service via X-APP-ID / X-APP-KEY
Policy "JwtOrAppKey" either scheme Default — accepts both
Policy "RequireAppKey" AppKey only Internal/cluster endpoints

Use [Authorize(Policy = "RequireAppKey")] on endpoints that should only accept app credentials.

2.4 Token extraction priority

For JWT Bearer, the middleware reads tokens in this order per request:

  1. Cookie — name comes from discovery (mes_auth_token by default)
  2. Authorization: Bearer <token> header
  3. ?access_token= query parameter — only for paths starting with /hub (SignalR)

2.5 Bypassing permission checks

Two mechanisms skip PermMiddleware:

// Option A: standard ASP.NET [AllowAnonymous]
app.MapGet("/public", () => "hello").AllowAnonymous();

// Option B: MesAuth-specific bypass (keeps auth cookies but skips permission lookup)
app.MapGet("/webhook", WebhookHandler).MesAuthException();

// Option C: [MesAuthExceptionAttribute] on a controller action
[MesAuthException]
public IActionResult Callback() { ... }

MesAuthException is useful for callbacks that receive valid JWTs but whose permission code isn't registered in MesAuth (e.g., OAuth callbacks, approval webhooks from MesAuth itself).

2.6 Key rotation

When MesAuth rotates its RS256 signing key, the JwtBearer OnAuthenticationFailed event catches SecurityTokenSignatureKeyNotFoundException, calls DiscoveryService.RefreshAsync(), fetches the new JWKS, and retries validation in the same request — transparent to callers. Concurrent requests during rotation all wait on a single-flight semaphore so only one HTTP call is made.


3. AI schema discovery

When DiscoverAiSchemas = true (the default), the library auto-registers your API's OpenAPI schemas with MesAuth so the AI assistant can describe and call your endpoints.

3.1 How it works

After ApplicationStarted, EndpointSchemaDiscoveryService waits AiDiscoveryDelaySeconds seconds (default 30), then:

  1. Resolves an OpenAPI document — tries Swashbuckle (ISwaggerProvider) first, then the built-in AddOpenApi() (OpenApiDocumentService). If neither is registered it exits cleanly.
  2. Extracts per-operation request/response/path-parameter JSON Schemas.
  3. POSTs them in batches of AiDiscoveryBatchSize (default 25) to /function/register-schemas on MesAuth.Api.

3.2 Options

Option Default Notes
DiscoverAiSchemas true Set false to disable entirely
AiDiscoveryDelaySeconds 30 Delay after startup before POSTing. Set higher for slow-starting services
AiDiscoveryBatchSize 25 Operations per POST request
AiDiscoverySwaggerDocName "v1" The Swashbuckle document name (passed to ISwaggerProvider.GetSwagger)
await builder.Services.AddMesAuthAsync(opt =>
{
    // ...base options...
    opt.DiscoverAiSchemas        = true;
    opt.AiDiscoveryDelaySeconds  = 60;   // give slow migrations time to finish
    opt.AiDiscoveryBatchSize     = 50;
    opt.AiDiscoverySwaggerDocName = "v1";
});

3.3 Auth server: skip the HTTP self-call

When IsAuthServer = true, the HTTP self-call to register schemas hits the service's own external hostname, which IIS loopback cannot reach. Use LocalEndpointSchemaRegisterResolver instead:

opt.IsAuthServer = true;
opt.LocalEndpointSchemaRegisterResolver = async (appId, appKey, ops) =>
{
    // Call your in-process registration service directly
    return await schemaService.RegisterAsync(appId, appKey, ops);
};

4. Cluster / shared cache (Main.Api)

In a multi-instance deployment, each consumer app instance has its own in-process IMemoryCache. Without coordination, a key rotation forces every instance to independently re-fetch JWKS; a user permission change isn't visible until each instance's cache expires; and a token refresh on one instance produces a new access token that other instances haven't heard about, causing concurrent silent-refresh storms.

Main.Api (the YARP reverse-proxy layer) exposes /internal/cache/* and /internal/coord/* endpoints that act as a shared, cluster-wide in-memory store. Setting MainBaseUrl wires all consumer instances into this shared cache automatically.

4.1 Enabling

"MesAuth": {
  "MainBaseUrl": "https://mes.kefico.vn"
}

Or in Program.cs:

opt.MainBaseUrl = builder.Configuration["MesAuth:MainBaseUrl"];
// Leave null/empty for single-instance deployments — all operations become no-ops.

MainBaseUrl is auto-derived from Issuer when not set and DirectDiscoveryBaseUrl is absent. For example, Issuer = "https://mes.kefico.vn/auth"MainBaseUrl = "https://mes.kefico.vn". Set it explicitly if Main.Api is at a different address than the auth server.

Exception — Main.Api itself: Main.Api IS the proxy. Setting MainBaseUrl would cause it to call its own /internal/* endpoints circularly. Use DirectDiscoveryBaseUrl instead, which suppresses auto-derive:

"MesAuth": {
  "DirectDiscoveryBaseUrl": "http://localhost:7199"
}

4.2 What gets cached in Main.Api

Data L1 (in-process) TTL L2 (Main.Api) TTL L2 key prefix
OpenID discovery config + JWKS 5 min (with L2), 30 min (standalone) 25 min ma:disc:
User permissions 2 min (with L2), CacheTtl (standalone) max(60s, CacheTtl) ma:uperm:{appId}:
App/service permissions 2 min (with L2), CacheTtl (standalone) max(60s, CacheTtl) ma:aperm:
Refresh token results 30 s (in-process fallback) 30 s ma:refresh:

All L2 keys are hashed: prefix + first-8-bytes-of-SHA256(input) as lowercase hex. You will never see a raw user ID or token in Main.Api's store.

4.3 L1 + L2 cache flow (per request)

Request arrives
  └─ L1 hit?  → return immediately (no network)
  └─ L2 hit?  → populate L1, return (one Main.Api HTTP call)
  └─ origin fetch (MesAuth.Api) → populate L1 + L2 (fire-and-forget)

L1 is always checked first. When MainBaseUrl is set, L1 TTLs are shortened (2 min for permissions, 5 min for discovery) so that changes published to L2 by other instances become visible within the L1 window.

4.4 Behavior when Main.Api is unreachable

IClientCacheService catches all exceptions and returns null/false/0 without throwing. The system degrades gracefully:

  • Discovery — falls through to origin (MesAuth.Api direct call)
  • Permissions — falls through to origin (MesAuth.Api direct call); results cached in L1 only
  • Refresh results — falls back to in-process IMemoryCache (30 s sliding TTL)
  • Semaphores / queuesAcquireSemaphoreAsync returns false; QueuePopAsync returns null

All Main.Api failures are logged at Warning level, rate-limited to once per 60 seconds, so a sustained outage doesn't flood logs.


5. IClientCacheService — full reference

Inject IClientCacheService directly in your own services to use Main.Api's shared cache for application-level data. When MainBaseUrl is not configured, all calls are no-ops.

public class MyService
{
    private readonly IClientCacheService _cache;
    public MyService(IClientCacheService cache) => _cache = cache;
}

5.1 Key/value store

// Get — returns null on miss or when Main.Api is unavailable
string? value = await _cache.GetAsync("my:key");

// Set — ttlSeconds = 0 means no expiry
await _cache.SetAsync("my:key", "hello", ttlSeconds: 300);

// Delete
await _cache.DeleteAsync("my:key");

All values are string. Serialize complex objects yourself:

var json = JsonSerializer.Serialize(myDto);
await _cache.SetAsync("config:v2", json, ttlSeconds: 600);

var raw = await _cache.GetAsync("config:v2");
var dto = raw != null ? JsonSerializer.Deserialize<MyDto>(raw) : null;

5.2 Distributed semaphore

var token = Guid.NewGuid().ToString(); // unique per acquisition attempt
bool acquired = await _cache.AcquireSemaphoreAsync(
    key:    "job:report-gen",
    ttlMs:  30_000,   // auto-release after 30 s (guards against crashes)
    token:  token);

if (acquired)
{
    try   { await RunExpensiveJobAsync(); }
    finally { await _cache.ReleaseSemaphoreAsync("job:report-gen", token); }
}
else
{
    // Another instance holds the lock — skip or queue
}

token is a fencing token: ReleaseSemaphoreAsync returns false if the token doesn't match the current owner, preventing accidental release of someone else's lock. Choose TTL conservatively — a crashed holder releases automatically after ttlMs milliseconds.

5.3 Named work queue

// Producer — push serialized work items
int queueLength = await _cache.QueuePushAsync("email:queue", JsonSerializer.Serialize(job));

// Consumer — pop one item at a time (FIFO)
string? raw = await _cache.QueuePopAsync("email:queue");
if (raw != null)
{
    var job = JsonSerializer.Deserialize<EmailJob>(raw);
    await SendEmailAsync(job);
}

The queue is backed by an in-memory list in Main.Api — it is not durable. If Main.Api restarts, queued items are lost. Use it for short-lived coordination (e.g., "notify all instances to reload config"), not as a message broker.

5.4 Key naming conventions

Follow the library's own convention: prefix:qualifier in lowercase, with ShortHash for sensitive inputs.

ma:disc:          — discovery (internal, do not reuse)
ma:uperm:{appId}: — user permissions (internal)
ma:aperm:         — app permissions (internal)
ma:refresh:       — refresh results (internal)

yourapp:config:v1     — good: namespace + purpose + version
yourapp:lock:report   — good: namespace + purpose
yourapp:q:notify      — good: namespace + "q" for queues

Avoid raw user IDs or tokens in keys. Hash sensitive inputs with SHA256 before using them as key segments.


6. Concurrency primitives — semaphores & work queues

6.1 In-process single-flight (IRefreshCoordinator)

The library registers IRefreshCoordinator as a singleton. It provides an in-process SemaphoreSlim-per-token pattern so that concurrent requests sharing the same refresh token only trigger one actual refresh call.

You can inject and use it for your own single-flight patterns:

public class MyService
{
    private readonly IRefreshCoordinator _coord;
    public MyService(IRefreshCoordinator coord) => _coord = coord;

    public async Task<bool> DoOncePerKeyAsync(string key, Func<Task<bool>> action)
    {
        // Only one caller executes 'action' per unique key at a time.
        // Concurrent callers with the same key queue and all receive the result.
        return await _coord.TryRefreshAsync(key, action);
    }
}

IRefreshCoordinator is in-process only — it does not coordinate across instances. For cross-instance single-flight use IClientCacheService.AcquireSemaphoreAsync.

6.2 Cross-instance leader election pattern

public async Task RunOnceAcrossClusterAsync(CancellationToken ct)
{
    var token = Guid.NewGuid().ToString();
    const string key = "myapp:leader:daily-job";
    const int ttlMs  = 60_000; // 60 s — must exceed job duration

    bool isLeader = await _cache.AcquireSemaphoreAsync(key, ttlMs, token, ct);
    if (!isLeader) return; // another instance won

    try
    {
        await RunDailyJobAsync(ct);
    }
    finally
    {
        await _cache.ReleaseSemaphoreAsync(key, token, ct);
    }
}

When MainBaseUrl is not configured, AcquireSemaphoreAsync always returns false, so the pattern safely degrades: every instance runs independently (same as single-instance mode).


7. appsettings.json reference

{
  "MesAuth": {

    // ── Required ──────────────────────────────────────────────────────────────

    // Application identity registered in MesAuth.Api
    "AppId": "MYAPP",
    "AppKey": "your-secret-key-at-least-16-chars",

    // Path to the OpenID Connect discovery document on MesAuth.Api
    // Always "/.well-known/openid-configuration" unless customized
    "WellknowConfigUri": "/.well-known/openid-configuration",

    // Public base URL of MesAuth.Api including path prefix
    // Used as: JWT issuer claim, token endpoint prefix, permission endpoint prefix
    "Issuer": "https://mes.kefico.vn/auth",

    // ── Recommended ───────────────────────────────────────────────────────────

    // Set true only for MesAuth.Api itself — tells the library it IS the auth server
    // Consumer apps must leave this false (the default)
    "IsAuthServer": false,

    // Post endpoints to MesAuth at startup so the admin console can manage permissions
    "AutoRegisterEndpoints": true,

    // Minutes before token expiry to trigger a proactive silent refresh
    // Prevents the auth server and consumer from racing to refresh the same token
    // 0 = disable proactive refresh (tokens are only refreshed after expiry)
    "ProactiveRefreshMinutes": 1,

    // ── Cluster / shared cache ─────────────────────────────────────────────────

    // Base URL of Main.Api (the YARP gateway and shared cache host)
    // When set: L2 cache for discovery, permissions, and refresh results uses Main.Api
    // When omitted: auto-derived from Issuer (e.g. https://mes.kefico.vn/auth → https://mes.kefico.vn)
    // Set explicitly when Main.Api is at a different host than the auth server
    // Set to "" to disable L2 cache completely (single-instance deployments)
    "MainBaseUrl": "https://mes.kefico.vn",

    // Only needed when THIS service IS Main.Api (the YARP proxy).
    // Points directly at the auth backend, bypassing YARP, for startup discovery/JWKS calls.
    // Also suppresses MainBaseUrl auto-derive to prevent circular /internal/* calls.
    // Leave unset for all other consumer apps.
    "DirectDiscoveryBaseUrl": "",

    // ── Permission cache ───────────────────────────────────────────────────────

    // How long to cache user/app permissions in L1 (in-process)
    // Sliding expiry — clock resets on each cache hit
    // Default: 15 minutes. Lower = more MesAuth.Api traffic; Higher = slower permission revocation
    // When MainBaseUrl is set, L1 TTL is capped at 2 min regardless of this setting;
    // L2 TTL = max(60s, CacheTtl) in seconds
    // "CacheTtl": "00:15:00",  // not a top-level appsettings key — set via code (see §8)

    // ── AI schema discovery ────────────────────────────────────────────────────

    // Push OpenAPI schemas to MesAuth so the AI assistant can describe and call your endpoints
    "DiscoverAiSchemas": true,
    "AiDiscoveryDelaySeconds": 30,   // wait after startup before POSTing
    "AiDiscoveryBatchSize": 25,      // operations per POST
    "AiDiscoverySwaggerDocName": "v1"
  }
}

CacheTtl is not read from configuration — set it in the configure callback:
opt.CacheTtl = TimeSpan.FromMinutes(10);


8. Program.cs reference

8.1 Minimal consumer app

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

await builder.Services.AddMesAuthAsync(opt =>
{
    opt.AppId             = builder.Configuration["MesAuth:AppId"]!;
    opt.AppKey            = builder.Configuration["MesAuth:AppKey"]!;
    opt.WellknowConfigUri = builder.Configuration["MesAuth:WellknowConfigUri"]!;
    opt.Issuer            = builder.Configuration["MesAuth:Issuer"]!;
});

var app = builder.Build();

app.UseCors();
app.UseMesAuth(); // = UseAuthentication + UseAuthorization + PermMiddleware
app.MapControllers();

app.Run();

8.2 Full consumer app (all options)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); // required for AI schema discovery

// CORS — must be registered before UseMesAuth()
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
    ?? ["https://mes.kefico.vn"];
builder.Services.AddCors(opt =>
    opt.AddDefaultPolicy(p => p.WithOrigins(origins)
        .WithMethods("GET","POST","PUT","DELETE","OPTIONS","PATCH")
        .AllowAnyHeader()
        .AllowCredentials()));

await builder.Services.AddMesAuthAsync(opt =>
{
    opt.AppId             = builder.Configuration["MesAuth:AppId"]!;
    opt.AppKey            = builder.Configuration["MesAuth:AppKey"]!;
    opt.WellknowConfigUri = builder.Configuration["MesAuth:WellknowConfigUri"]!;
    opt.Issuer            = builder.Configuration["MesAuth:Issuer"]!;
    opt.IsAuthServer      = false;
    opt.AutoRegisterEndpoints  = builder.Configuration.GetValue<bool>("MesAuth:AutoRegisterEndpoints", true);
    opt.ProactiveRefreshMinutes = builder.Configuration.GetValue<int>("MesAuth:ProactiveRefreshMinutes", 1);
    opt.MainBaseUrl       = builder.Configuration["MesAuth:MainBaseUrl"];
    opt.CacheTtl          = TimeSpan.FromMinutes(15);

    // AI schema discovery
    opt.DiscoverAiSchemas        = true;
    opt.AiDiscoveryDelaySeconds  = 30;
    opt.AiDiscoveryBatchSize     = 25;
    opt.AiDiscoverySwaggerDocName = "v1";
});

builder.Services.AddHealthChecks();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.UseCors();        // ← before UseMesAuth
app.UseMesAuth();     // ← Authentication + Authorization + PermMiddleware

app.MapHealthChecks("/health").AllowAnonymous();
app.MapControllers();

app.Run();

8.3 Auth server (IsAuthServer = true)

The auth server skips HTTP self-calls for discovery and permission resolution — it reads directly from its own in-process objects.

await builder.Services.AddMesAuthAsync(opt =>
{
    opt.AppId             = "MESAUTH";
    opt.AppKey            = builder.Configuration["MesAuth:AppKey"]!;
    opt.WellknowConfigUri = "/.well-known/openid-configuration";
    opt.Issuer            = "https://mes.kefico.vn/auth";
    opt.IsAuthServer      = true;

    // Skip HTTP self-call for discovery (IIS can't loopback to external hostname)
    opt.LocalDiscoveryConfig = new DiscoveryConfig
    {
        Issuer    = "https://mes.kefico.vn/auth",
        JwksUri   = "/.well-known/jwks.json",
        Audience  = "mes-api-clients",
        // ... other fields from your JwtAuthConfiguration
    };

    // Skip HTTP self-call for permission lookup
    opt.LocalPermissionResolver = async (userId, appId) =>
        await permService.GetUserPermissionsAsync(userId, appId);

    // Skip HTTP self-call for refresh tokens
    opt.LocalRefreshTokenResolver = async (ctx, refreshToken) =>
    {
        var result = await tokenService.RefreshAsync(refreshToken);
        return new TokenResponse(result.AccessToken, result.Message, result.RefreshToken);
    };

    // Skip HTTP self-call for function registration
    opt.LocalFunctionRegisterResolver = async (appId, appKey, functions) =>
        await funcService.RegisterAsync(appId, appKey, functions);

    // Skip HTTP self-call for schema registration
    opt.LocalEndpointSchemaRegisterResolver = async (appId, appKey, schemas) =>
        await schemaService.RegisterAsync(appId, appKey, schemas);

    // Disable L2 cache — don't call your own /internal/cache/* endpoint
    opt.MainBaseUrl = null;
    // DirectDiscoveryBaseUrl is not needed when LocalDiscoveryConfig is set
});

8.4 Main.Api (the YARP gateway — special case)

Main.Api is both a consumer of MesAuth.Authorizer and the YARP reverse proxy that routes to MesAuth.Api. It must not route auth discovery calls through itself.

await builder.Services.AddMesAuthAsync(opt =>
{
    opt.AppId             = builder.Configuration["MesAuth:AppId"]!;
    opt.AppKey            = builder.Configuration["MesAuth:AppKey"]!;
    opt.WellknowConfigUri = builder.Configuration["MesAuth:WellknowConfigUri"]!;
    opt.Issuer            = builder.Configuration["MesAuth:Issuer"]!;
    opt.IsAuthServer      = false;
    opt.AutoRegisterEndpoints = builder.Configuration.GetValue<bool>("MesAuth:AutoRegisterEndpoints", false);
    opt.DiscoverAiSchemas = false; // Main.Api has no OpenAPI doc of its own
    opt.ProactiveRefreshMinutes = builder.Configuration.GetValue<int>("MesAuth:ProactiveRefreshMinutes", 1);

    // Bypass YARP for startup discovery — points directly at the auth backend
    opt.DirectDiscoveryBaseUrl = builder.Configuration["MesAuth:DirectDiscoveryBaseUrl"];
    // Suppresses MainBaseUrl auto-derive (would cause circular /internal/* calls)
    opt.MainBaseUrl = builder.Configuration["MesAuth:MainBaseUrl"]; // leave null for Main.Api
});

9. HttpContext extension methods

All extensions are in MesAuth.Authorizer.AuthenServiceExtension.

9.1 User identity

IUser? user = context.GetUser();

// IUser fields (all nullable strings unless noted):
user.UserId          // JWT "sub" claim
user.UserName        // JWT "name" claim
user.FullName        // JWT "family_name" claim
user.Email
user.Department
user.Position
user.EmployeeCode
user.Roles           // HashSet<string> from "roles" claim
// HR fields: HrFullNameVn, HrFullNameEn, HrPosition, HrJobTitle,
//            HrGender, HrMobile, HrEmail, HrJoinDate, HrBirthDate,
//            HrWorkStatus, HrDoiTuong, HrTeamCode, HrLineCode, HrTeamId, HrLineId

Available in SignalR hubs too:

public override async Task OnConnectedAsync()
{
    var user = Context.GetUser(); // HubCallerContext extension
}

9.2 Permission inspection

// Permissions granted in this request (populated by PermMiddleware)
HashSet<string>? perms = context.GetCurrentPermissions();
HashSet<string>? userPerms = context.GetUserPermissions(); // JWT path only
HashSet<string>? appPerms  = context.GetAppPermissions();  // AppKey path only

// Auth type
bool isUser = context.IsUserAuthenticated();  // JWT bearer
bool isApp  = context.IsAppAuthenticated();   // X-APP-ID / X-APP-KEY

// Caller app identity (set when X-APP-ID / X-APP-KEY pass validation)
string? appId = context.GetCallerAppId();
bool hasApp   = context.HasCallerApp();

// Permission code that PermMiddleware resolved for this request
string? code = context.GetPermissionCode(); // e.g. "MYAPP.Api.Users.Get"

// Best actor for audit logs: UserName → CallerAppId → fallback
string actor = context.ResolveActor("System");

9.3 Centralized audit logging

Fire-and-forget HTTP call to MesAuth.Api /sys-logs/write:

// Shorthand helpers
context.LogInfo(ClientLogCategory.UserManagement, "UserCreated", "Created user foo");
context.LogWarn(ClientLogCategory.Authorization,  "PermDenied",  "Attempted admin action");
context.LogError(ClientLogCategory.Security, "SuspiciousLogin", "Rate limit hit", exception: ex);

// Full control
context.WriteLog(
    level:          ClientLogLevel.Information,
    category:       ClientLogCategory.Authentication,
    eventType:      "LoginSuccess",
    message:        "User logged in",
    details:        new { ip = "10.1.1.1", method = "LDAP" },
    targetUserId:   Guid.Parse("..."),
    targetUserName: "other.user");

10. Common pitfalls

UseCors() after UseMesAuth()

UseMesAuth() appends expose headers via Response.OnStarting — a LIFO callback list. The CORS Access-Control-Allow-Origin header is also written in OnStarting. If CORS runs after MesAuth's callback, CORS headers are absent and browsers reject the response.

❌  app.UseMesAuth(); app.UseCors();
✅  app.UseCors();    app.UseMesAuth();

AddMesAuthAsync not awaited

The method is async and performs a startup HTTP call. If you forget await it silently runs in the background — services may be registered out of order or the DI container may build before JWT keys arrive.

❌  builder.Services.AddMesAuthAsync(opt => { ... });
✅  await builder.Services.AddMesAuthAsync(opt => { ... });

AppKey too short

ValidateOptions throws at startup if AppKey.Length < 16. Use at least a GUID-length secret.

Missing MainBaseUrl in multi-instance deployment

Without MainBaseUrl, each instance caches permissions independently. After a permission grant/revoke, some instances see the change immediately (their cache expired) while others serve stale permissions for up to CacheTtl (default 15 min). Set MainBaseUrl to point all instances at Main.Api's shared cache.

Main.Api setting its own MainBaseUrl

Main.Api is the shared cache host. If it sets MainBaseUrl pointing to itself, DiscoveryService and PermMiddleware call their own /internal/* endpoints — circular requests that time out at startup. Use DirectDiscoveryBaseUrl (which also suppresses MainBaseUrl auto-derive) instead.

Clock skew

Tokens are validated with ClockSkew = TimeSpan.FromMinutes(2). If the server clock drifts beyond ±2 minutes from the auth server's clock, freshly issued tokens fail validation. Ensure NTP is configured on all hosts. Do not increase ClockSkew to paper over infrastructure problems.

[AllowAnonymous] vs [MesAuthException]

[AllowAnonymous] bypasses all middleware including PermMiddleware — the user is unauthenticated, GetUser() returns null, and no auth cookie is read.

[MesAuthException] (or .MesAuthException()) bypasses only PermMiddleware's permission check. Auth still runs, cookies are still processed, GetUser() returns the authenticated user if a valid JWT/cookie is present. Use this for endpoints that need the user identity but whose permission code isn't registered (callbacks, webhooks from MesAuth itself, approval callbacks).

Permission code staleness after rename

EndpointRegistrationService derives permission codes from route templates at startup: AppId.RouteSegments.HttpMethod. If you rename a route (e.g., api/usersapi/members), the new code is registered but any existing role grants attached to the old code are orphaned — users lose access until an admin re-grants under the new code. Treat permission codes as stable identifiers, not cosmetic names.

Proactive refresh race (two-sided refresh)

Without proactive refresh (ProactiveRefreshMinutes = 0), a near-expiry request arrives at both the consumer app and MesAuth.Api simultaneously — the consumer triggers a refresh on the token it just used, while MesAuth simultaneously processes it as a fresh request. Both sides may attempt to refresh the same token concurrently. Set ProactiveRefreshMinutes to a value (default 5, minimum 1 for active deployments) so the consumer refreshes before MesAuth sees the token as expired.

Negative-cache on permission failure

When PermMiddleware cannot reach MesAuth.Api after 3 retries (network failure, 5xx), it stores a null sentinel in L1 for 10 seconds with an absolute expiration. During this window, affected users receive 403/401 even if they have permission, to prevent hammering a struggling auth server. After 10 seconds the negative cache expires and normal lookups resume automatically. This is intentional — do not remove the negative-cache to avoid thundering-herd restorms.

X-Internal-Refresh header

The library marks its own token-refresh HTTP calls with X-Internal-Refresh: true. PermMiddleware.IsLoopbackOrAnonymous checks this header and skips permission lookup, preventing an infinite recursion when the refresh call itself is routed through the same middleware. Do not strip this header in a proxy layer.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.23.0 92 6/1/2026
10.22.0 86 5/30/2026
10.21.0 94 5/28/2026
10.20.1 100 5/25/2026
10.20.0 98 5/25/2026
10.19.1 94 5/25/2026
10.19.0 89 5/24/2026
10.18.0 92 5/24/2026
10.17.0 96 5/24/2026
10.16.2 88 5/21/2026
10.16.1 90 5/21/2026
10.16.0 91 5/21/2026
10.15.7 107 5/19/2026
10.15.6 96 5/19/2026
10.15.5 96 5/19/2026
10.15.4 93 5/19/2026
10.15.3 106 5/19/2026
10.15.1 94 5/18/2026
10.15.0 91 5/18/2026
10.14.0 94 5/18/2026
Loading failed