hexasync.auth.middleware 2609.8.1
HexaSync Auth Middleware
Authentication middleware for HexaSync services. Validates JWTs signed with ECDSA (ES256/384/512) via JWKS, and
HMAC (HS256/384/512) via a shared secret, choosing the path from the token's alg header.
Scope of this document: everything below describes the package as it is implemented today. If a knob is not listed here, it does not exist.
Installation
The package is published to the private feed https://nuget.hexasync.com/, not nuget.org — add it as a source first.
dotnet add package hexasync.auth.middleware --version 2609.7.1
Or in your .csproj — versions are calendar-based (YYMM.D.N, day not zero-padded: 2609.7.1 is 2026-09-07), so
pin the current one rather than a semver-looking value:
<PackageReference Include="hexasync.auth.middleware" Version="2609.7.1" />
Quick Start
Environment variables (recommended)
SSO_SERVICE_URL=https://sso.hexasync.com # SSO_URL is accepted as a legacy fallback
SSO_SERVICE_SECRET=your-hmac-secret # only needed if this service receives HS* tokens
JWT_ISSUER=... # optional; see default in the table below
JWT_AUDIENCE=... # optional; see default in the table below
// Program.cs
builder.Services.AddHexaSyncAuthentication();
These are the only options this package reads from the environment — four options, across five variable names
(SSO_URL is a legacy alias for SSO_SERVICE_URL). The package never binds IConfiguration itself, so the
[ConfigurationKeyName] attributes on the options class are inert unless you bind configuration yourself — and bind the
root, not the HexaSyncAuth section, because those attributes name flat keys (SSO_SERVICE_URL, not
HexaSyncAuth__SsoUrl).
Configuring in code
builder.Services.AddHexaSyncAuthentication(options =>
{
options.SsoUrl = "https://sso.hexasync.com";
options.HmacSecretKey = builder.Configuration["Auth:HmacSecret"];
});
Important
The overloads that take an Action<HexaSyncAuthOptions> do not read environment variables. If you switch from
AddHexaSyncAuthentication() to AddHexaSyncAuthentication(options => …), SSO_SERVICE_URL, SSO_SERVICE_SECRET,
JWT_ISSUER and JWT_AUDIENCE stop being applied and every option falls back to its default — including SsoUrl,
which drives the JWKS endpoint. Set every value you need in the delegate, or use an environment-binding overload.
| Reads environment variables | Does not |
|---|---|
AddHexaSyncAuthentication() |
AddHexaSyncAuthentication(Action<…>) |
AddHexaSyncAuthentication(scheme) |
AddHexaSyncAuthentication(scheme, Action<…>) |
AddHexaSync(builder) |
AddHexaSync(builder, Action<…>) |
AddHexaSync(builder, scheme) |
AddHexaSync(builder, scheme, Action<…>) |
Configuration Options
| Option | Type | Default | Notes |
|---|---|---|---|
SsoUrl |
string | https://sso.hexasync.com |
Env: SSO_SERVICE_URL, or SSO_URL (legacy) |
JwksEndpoint |
string | computed | Read-only. Always {SsoUrl}/.well-known/jwks.json. Change SsoUrl to change it |
HmacSecretKey |
string? | null |
Env: SSO_SERVICE_SECRET. When null, HS* tokens are refused |
Issuer |
string | HEXASYNC-IPAAS-WORLD-TOP-DATA-INTEGRATION |
Env: JWT_ISSUER |
Audience |
string | HEXASYNC-IPAAS-WORLD-TOP-DATA-INTEGRATION |
Env: JWT_AUDIENCE |
Audiences |
IEnumerable<string>? |
null |
Takes precedence over Audience when set |
ValidateIssuer |
bool | true |
|
ValidateAudience |
bool | true |
|
ValidateLifetime |
bool | true |
|
ClockSkew |
TimeSpan | 5 minutes | |
JwksCacheDuration |
TimeSpan | 24 hours | Absolute expiry. A non-configurable 6-hour sliding expiry applies as well: the entry is dropped after 6 h with no lookups, so a service idle longer than that refetches on its next request. Eviction is lazy — there is no background refresh |
JwksRequestTimeout |
TimeSpan | 30 seconds | |
RequireHttpsMetadata |
bool | true |
⚠️ Declared but never read. Nothing enforces it. Ensure SsoUrl is HTTPS yourself |
AuthenticationScheme |
string | HexaSync |
⚠️ Declared but never read. Setting this property has no effect; the scheme is whatever you pass to AddHexaSync*(scheme), defaulting to HexaSync |
Multiple Authentication Schemes
builder.Services.AddAuthentication()
.AddHexaSync("HexaSync", options =>
{
options.SsoUrl = "https://sso.hexasync.com";
options.HmacSecretKey = builder.Configuration["Auth:HmacSecret"];
})
.AddJwtBearer("AnotherScheme", options =>
{
// Other JWT configuration — AddJwtBearer needs
// Microsoft.AspNetCore.Authentication.JwtBearer, which this package does not reference
});
Note
The two families differ in more than env binding. AddHexaSyncAuthentication* also sets
DefaultAuthenticateScheme and DefaultChallengeScheme to the scheme it registers. The
AddHexaSync(builder, …) family does not — it only adds the scheme, leaving the defaults to whatever
AddAuthentication(...) established. Use the builder family when HexaSync should not be the default.
What AddHexaSync* Registers
IJwksKeyProvider→JwksKeyProvider(viaAddHttpClient, so the concrete registration is transient)IDualModeTokenValidator→DualModeTokenValidator(singleton)IEcdsaInnerServiceTokenResolver→StandardEcdsaInnerServiceTokenResolver(scoped,TryAdd)IMemoryCache- The
HexaSyncauthentication scheme and its handler
Token Validation
Validation routes on the JWT header alg:
alg |
Path |
|---|---|
ES256, ES384, ES512 |
JWKS keys from {SsoUrl}/.well-known/jwks.json. If kid is present and cached, only that key is used; if the kid is not in the cache, all keys are tried. On SecurityTokenSignatureKeyNotFoundException the keys are refreshed and validation is retried once |
HS256, HS384, HS512 |
HmacSecretKey. If it is not configured, the token is refused |
| anything else | Refused |
The handler adds a token_validation_method claim recording which path succeeded:
var method = User.FindFirst("token_validation_method")?.Value; // "ECDSA" or "HMAC"
Capability-Based Authorization
Capabilities are resolved automatically during authentication by HexaSyncAuthenticationHandler — there is no
middleware to add and no separate registration call in this package.
Prerequisite
Resolution needs a working IEcdsaInnerServiceTokenResolver. AddHexaSync* registers the type with TryAddScoped, but
its dependencies — an HttpClient, IOptions<ClientCredentialsOptions>, IDistributedCache and an
EcdsaTokenRefreshCoordinator — come from the hexasync.services.proxy package:
builder.Services.AddEcdsaInnerServiceResolver(builder.Configuration);
// env: SSO_SERVICE_URL, SSO_CLIENT_ID, SSO_CLIENT_SECRET
If the resolver cannot be obtained, capability resolution is skipped and the request proceeds with no capabilities.
Resolution flow
Driven by the system_role claim, then narrowed by the calling application:
| Token type | Claims | Effective capabilities |
|---|---|---|
| User OAuth | sub, client_id, system_role |
user caps ∩ app caps |
| Client credentials | client_id, system_role |
app caps (from the app's role) |
| First-party SSO UI | sub, client_id, system_role |
user caps — because that application declares no capabilities, not because the handler special-cases it |
There is no first-party branch in the code. One rule decides all three rows: an application that declares
capabilities narrows the caller to the intersection; one that declares nothing does not narrow (so the caller keeps
its full user set); one whose capabilities cannot be resolved gets nothing (fail-closed); and a token carrying no
client_id is granted nothing. See CapabilityIntersection and ApplicationCapabilityStatus for the exact rule.
Protecting endpoints
// Minimal API — OR (any of)
app.MapGet("/users", GetUsers).RequireCapability("users:read", "admin:full_access");
// Minimal API — AND (all of)
app.MapPost("/users", CreateUser).RequireAllCapabilities("users:write", "audit:enabled");
// Minimal API — require active sudo mode (re-verified identity).
// Otherwise 403 with body {"code":"SUDO_REQUIRED","message":"…"} — note the property is `code`, not `error`.
app.MapDelete("/users/{id}", DeleteUser).RequireSudo();
// MVC
[RequireCapability("users:read", "admin:full_access")]
public IActionResult GetUsers() { ... }
[RequireCapability("users:write", "audit:enabled", RequireAll = true)]
public IActionResult CreateUser() { ... }
Checking capabilities in code
using hexasync.auth.middleware.Capabilities;
if (httpContext.HasCapability(AppCapabilities.Sso.OAuthApps.Write)) { ... }
if (httpContext.HasAnyCapability(AppCapabilities.Sso.OAuthApps.Read, "admin:full_access")) { ... }
if (httpContext.HasAllCapabilities(AppCapabilities.Sso.OAuthApps.Read,
AppCapabilities.Sso.OAuthApps.Write)) { ... }
IReadOnlyList<string> capabilities = httpContext.GetCapabilities();
AppCapabilities holds the constants — e.g. AppCapabilities.Sso.OAuthApps.Read is "identity:oauth_apps:read".
Other extensions
using hexasync.auth.middleware.Extensions;
Guid? userId = httpContext.GetUserId(); // null for SERVICE tokens, or when no user id claim is present
Guid required = httpContext.GetRequiredUserId(); // throws in those same cases
string? client = httpContext.GetClientId();
bool isService = httpContext.IsServiceToken(); // how you tell a client-credentials token from a user one
bool isUser = httpContext.IsUserToken();
Guid? roleId = User.GetSystemRoleId(); // extension on ClaimsPrincipal, not HttpContext
RequireSudo() performs a claims-based check. If you register an ISudoValidationService, it is also consulted —
but only when the claims check has already passed and the token carries both sudo_iat and sub in parseable form.
A sudo token missing either skips the service entirely and is accepted on the claims check alone, so this is a
server-side invalidation hook rather than an unconditional gate.
Security Considerations
- Use HTTPS for
SsoUrl.RequireHttpsMetadataexists as an option but nothing enforces it, so this is on you. - Store
SSO_SERVICE_SECRETin a secret manager. It is a symmetric key shared across services: anyone holding it can mint a token every service accepts. - Key rotation. JWKS keys are cached for
JwksCacheDuration(24 hours by default, plus the 6-hour sliding expiry noted in the options table). A token bearing an unknownkidtriggers one refresh and one retry, so a newly published key is picked up without waiting for the cache to expire. - Clock skew. 5 minutes by default.
Troubleshooting
JWKS fetch fails — check connectivity to {SsoUrl}/.well-known/jwks.json, certificate validity, and firewall rules.
On failure the provider returns an empty key set and validation fails closed.
Token validation fails — check expiry, issuer/audience against the defaults in the options table (they are constants,
not derived from SsoUrl), and clock sync between services.
Options are unexpectedly at their defaults — you are probably on an Action<HexaSyncAuthOptions> overload, which does
not read environment variables. See the note under Quick Start.
Capabilities are always empty — AddEcdsaInnerServiceResolver is probably not registered, or the token carries no
system_role / client_id claim. A missing resolver and a missing system_role log at Debug; a token with no
client_id, and a resolver that throws, log at Warning.
Unexplained 403s — subscribe to the meter hexasync.auth.capability (CapabilityTelemetry.MeterName); it exposes
a hexasync.auth.capability.denied counter (CapabilityTelemetry.DeniedCounterName) incremented once per denial and
tagged with reason (see CapabilityDenialReasons). Note the counter name is not the meter name — subscribing to
hexasync.auth.capability.denied matches no meter and emits nothing, silently.
For a single request, CapabilityResolution.From(httpContext) returns the resolved set plus the
ApplicationCapabilityStatus — or null when authentication did not run or resolution bailed out early, i.e. no
system_role claim or no resolver registered. Those are the two Debug cases above, so a null here is itself the answer.
Note
CapabilityOptions (and the SSO_CAPABILITY_CACHE_MINUTES / SSO_REQUEST_TIMEOUT_SECONDS names in it) is
vestigial — nothing binds it and nothing reads it. Setting those has no effect. The live cache and timeout knobs
belong to ClientCredentialsOptions in hexasync.services.proxy.
License
MIT
No packages depend on hexasync.auth.middleware.
.NET 10.0
- Microsoft.AspNetCore.Mvc.Abstractions (>= 2.3.12)
- Microsoft.Extensions.Http (>= 10.0.10)
- Snappier (>= 1.3.1)
- StackExchange.Redis (>= 3.1.13)
- hexasync.services.proxy (>= 1.10.63)
| Version | Downloads | Last updated |
|---|---|---|
| 2610.24.2 | 0 | 10/24/2026 |
| 2610.24.1 | 0 | 10/24/2026 |
| 2610.19.2 | 0 | 10/19/2026 |
| 2610.17.1-pre.3 | 1 | 10/17/2026 |
| 2610.17.1-pre.2 | 0 | 10/17/2026 |
| 2610.15.1 | 6 | 10/15/2026 |
| 2610.7.2 | 0 | 10/07/2026 |
| 2610.7.1 | 0 | 10/07/2026 |
| 2610.3.2 | 0 | 10/03/2026 |
| 2610.3.1 | 0 | 10/03/2026 |
| 2610.2.2 | 0 | 10/02/2026 |
| 2610.2.1 | 0 | 10/02/2026 |
| 2610.1.2 | 0 | 10/01/2026 |
| 2610.1.1 | 62 | 10/01/2026 |
| 2609.29.2 | 1 | 09/29/2026 |
| 2609.29.1 | 0 | 09/29/2026 |
| 2609.28.2 | 17 | 09/28/2026 |
| 2609.28.1 | 0 | 09/28/2026 |
| 2609.25.2 | 0 | 09/25/2026 |
| 2609.25.1 | 2 | 09/25/2026 |
| 2609.23.2 | 2 | 09/23/2026 |
| 2609.23.1 | 1 | 09/23/2026 |
| 2609.22.1 | 136 | 09/22/2026 |
| 2609.16.2 | 0 | 09/16/2026 |
| 2609.16.1 | 7 | 09/16/2026 |
| 2609.15.2 | 2 | 09/15/2026 |
| 2609.15.1 | 2 | 09/15/2026 |
| 2609.12.2 | 3 | 09/12/2026 |
| 2609.12.1 | 0 | 09/12/2026 |
| 2609.10.5 | 0 | 09/11/2026 |
| 2609.10.4 | 45 | 09/10/2026 |
| 2609.10.3 | 2 | 09/10/2026 |
| 2609.10.2 | 0 | 09/10/2026 |
| 2609.10.1 | 4 | 09/10/2026 |
| 2609.8.2 | 19 | 09/08/2026 |
| 2609.8.1 | 29 | 09/08/2026 |
| 2609.7.2 | 0 | 09/07/2026 |
| 2609.7.1 | 16 | 09/07/2026 |
| 2609.4.2 | 0 | 09/04/2026 |
| 2609.4.1 | 4 | 09/04/2026 |
| 2609.4.1-pre.2 | 0 | 09/04/2026 |
| 2609.4.1-pre.1 | 0 | 09/04/2026 |
| 2609.3.1-pre.2 | 0 | 09/03/2026 |
| 2609.3.1-pre.1 | 0 | 09/03/2026 |
| 2609.2.1-pre.3 | 0 | 09/02/2026 |
| 2609.2.1-pre.2 | 0 | 09/02/2026 |
| 2608.27.1-pre.2 | 0 | 08/27/2026 |
| 2607.23.1 | 287 | 07/22/2026 |
| 2607.22.2 | 0 | 07/22/2026 |
| 2607.22.1 | 52 | 07/22/2026 |
| 2607.21.1 | 0 | 07/21/2026 |
| 2607.16.3 | 0 | 07/16/2026 |
| 2607.16.2 | 0 | 07/16/2026 |
| 2607.16.1 | 0 | 07/16/2026 |
| 2607.15.1 | 0 | 07/15/2026 |
| 2607.14.1 | 25 | 07/14/2026 |
| 2607.13.1 | 184 | 07/13/2026 |
| 2607.11.1 | 0 | 07/11/2026 |
| 2607.8.1 | 0 | 07/08/2026 |
| 2607.5.1 | 13 | 07/05/2026 |
| 2607.4.1 | 0 | 07/04/2026 |
| 2606.30.1 | 82 | 06/30/2026 |
| 2606.29.1 | 0 | 06/29/2026 |
| 2606.27.1 | 14 | 06/27/2026 |
| 2606.26.6 | 1 | 06/26/2026 |
| 2606.26.5 | 0 | 06/26/2026 |
| 2606.26.3 | 50 | 06/26/2026 |
| 2606.26.1 | 4 | 06/25/2026 |
| 2606.25.1 | 0 | 06/25/2026 |
| 2606.23.1 | 1 | 06/23/2026 |
| 2606.22.6 | 20 | 06/22/2026 |
| 2606.22.5 | 34 | 06/22/2026 |
| 2606.22.4 | 7 | 06/22/2026 |
| 2606.22.1 | 10 | 06/21/2026 |
| 2606.19.1 | 9 | 06/19/2026 |
| 2606.17.1 | 0 | 06/17/2026 |
| 2606.16.3 | 0 | 06/14/2026 |
| 2606.16.1 | 0 | 06/16/2026 |
| 2606.14.3 | 5 | 06/14/2026 |
| 2606.14.2 | 2 | 06/14/2026 |
| 2606.12.5 | 28 | 06/12/2026 |
| 2605.28.2 | 101 | 05/28/2026 |
| 2605.28.1 | 0 | 05/28/2026 |
| 2605.26.3 | 13 | 05/26/2026 |
| 2605.26.2 | 6 | 05/26/2026 |
| 2605.26.1 | 0 | 05/26/2026 |
| 2605.25.1 | 0 | 05/25/2026 |
| 2605.23.3 | 7 | 05/23/2026 |
| 2605.23.2 | 2 | 05/23/2026 |
| 2605.23.1 | 0 | 05/23/2026 |
| 2605.22.1 | 0 | 05/22/2026 |
| 2605.19.1 | 10 | 05/19/2026 |
| 2605.18.2 | 154 | 05/18/2026 |
| 2605.18.1 | 0 | 05/18/2026 |
| 2605.16.3 | 0 | 05/16/2026 |
| 2604.27.4 | 39 | 04/27/2026 |
| 2604.27.3 | 1 | 04/27/2026 |
| 2604.27.2 | 1 | 04/27/2026 |
| 2604.12.1 | 179 | 04/12/2026 |
| 2604.11.1 | 2 | 04/11/2026 |
| 2604.10.11 | 0 | 04/11/2026 |
| 2604.10.1-pre.1 | 5 | 04/10/2026 |
| 2604.8.2 | 17 | 04/08/2026 |
| 2604.3.2 | 147 | 04/03/2026 |
| 2603.23.3 | 3 | 03/23/2026 |
| 2603.23.2 | 0 | 03/23/2026 |
| 2603.23.1 | 0 | 03/23/2026 |
| 2603.19.2 | 77 | 03/19/2026 |
| 2603.19.1 | 0 | 03/19/2026 |
| 2603.18.1 | 46 | 03/18/2026 |
| 2603.16.3 | 223 | 03/16/2026 |
| 2603.15.2 | 2 | 03/15/2026 |
| 2603.13.1 | 1 | 03/13/2026 |
| 2602.26.3 | 10 | 03/12/2026 |
| 2602.24.1 | 0 | 03/14/2026 |
| 2602.13.1 | 0 | 03/14/2026 |
| 2602.11.1 | 0 | 03/14/2026 |
| 2602.9.2 | 2 | 03/14/2026 |
| 2601.15.1 | 53 | 03/09/2026 |
| 2601.7.2 | 0 | 03/14/2026 |
| 2601.7.1 | 0 | 03/14/2026 |
| 2601.2.1 | 0 | 03/14/2026 |
| 2601.1.2 | 0 | 03/14/2026 |
| 2601.1.1 | 0 | 03/14/2026 |
| 2512.28.1 | 0 | 03/14/2026 |
| 2512.27.3 | 0 | 03/14/2026 |
| 2512.27.2 | 0 | 03/14/2026 |
| 2512.27.1 | 0 | 03/14/2026 |
| 2512.26.1 | 0 | 03/14/2026 |
| 2512.24.1 | 0 | 03/14/2026 |
| 2512.20.1 | 57 | 03/09/2026 |
| 2512.13.1 | 0 | 03/14/2026 |
| 2512.12.2 | 0 | 03/14/2026 |
| 2512.12.1 | 0 | 03/14/2026 |
| 2512.11.4-pre | 0 | 03/14/2026 |
| 2512.11.2-pre | 0 | 03/14/2026 |