using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Mints and validates short-lived bearer tokens for SignalR hub connections. /// The token is a data-protected JSON payload containing the user's name, role /// claims, and granted dashboard visibility tags. Validity is enforced by the /// data-protection time-limited protector; no separate signing keys are configured. /// /// /// This service is registered as a singleton in /// and /// is shared by two consumer scopes: the /hubs/token endpoint (calls /// for a cookie-authenticated caller) and /// HubTokenAuthenticationHandler (transient, per-request; calls /// from the SignalR negotiate / connection path). Both /// serve external/remote hub consumers — server-rendered dashboard pages read the /// in-process feeds and never mint a hub token. /// The underlying is thread-safe, so /// minting and validating concurrently from any number of callers is safe; /// future maintainers should preserve the singleton lifetime to keep the /// protector instance stable. /// public sealed class HubTokenService { // Internal rather than private so a test can protect a hand-built payload through the same // purpose and assert how Validate reads a payload shape this class no longer mints (a token // predating the Tags field). Copying the literal into the test instead would let the two // drift and silently turn that test into an assertion about an unrelated protector. internal const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1"; // Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side // revocable. A short lifetime bounds the exposure window of a token captured from a proxy // or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and // bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant // the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at // mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub // connections within one lifetime — the natural place the deferred "tokens gain session // binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token // on every (re)connect, which is what a remote hub consumer is expected to do; see // docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation stays deferred. internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5); private readonly ITimeLimitedDataProtector _protector; private readonly IOptions _options; /// Initializes a new instance of the HubTokenService with a data protection provider. /// The data protection provider for token encryption. /// /// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map used to resolve the /// caller's granted visibility tags at mint time. /// public HubTokenService(IDataProtectionProvider dataProtection, IOptions options) { ArgumentNullException.ThrowIfNull(dataProtection); ArgumentNullException.ThrowIfNull(options); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); _options = options; } /// Issues a bearer token carrying the user's identity, roles, and granted tags. /// The claims principal representing the user. /// The data-protected bearer token string. public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime); /// /// Issues a bearer token that expires after the supplied lifetime. Test seam so a caller can /// mint an already-expired token deterministically without wall-clock delay; production callers /// use and get . /// /// The claims principal representing the user. /// The lifetime applied to the token; a non-positive value yields an already-expired token. /// The data-protected bearer token string. internal string Issue(ClaimsPrincipal user, TimeSpan lifetime) { ArgumentNullException.ThrowIfNull(user); // Resolved from the caller's LDAP-group claims rather than copied from any tag claims the // principal already carries: re-resolving is what makes the 5-minute lifetime an actual // staleness bound on the grant. Tags are stamped for every caller — an Administrator // bypasses the ACL, so theirs are simply moot rather than a special case here. IReadOnlySet grantedTags = DashboardGroupTagMapping.MapGroupsToTags( user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType).Select(c => c.Value), _options.Value.Dashboard.GroupToTag); HubTokenPayload payload = new( user.Identity?.Name, user.FindFirstValue(ClaimTypes.NameIdentifier), [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)], [.. grantedTags]); return _protector.Protect(JsonSerializer.Serialize(payload), lifetime); } /// Validates a token and returns the equivalent claims principal; null when invalid or expired. /// The token string to validate. /// The reconstructed , or when the token is missing, invalid, or expired. public ClaimsPrincipal? Validate(string? token) { if (string.IsNullOrEmpty(token)) { return null; } try { HubTokenPayload? payload = JsonSerializer.Deserialize(_protector.Unprotect(token)); if (payload is null) { return null; } if (string.IsNullOrEmpty(payload.Name) && string.IsNullOrEmpty(payload.NameIdentifier)) { return null; } List claims = []; if (!string.IsNullOrEmpty(payload.Name)) { claims.Add(new Claim(ClaimTypes.Name, payload.Name)); } if (!string.IsNullOrEmpty(payload.NameIdentifier)) { claims.Add(new Claim(ClaimTypes.NameIdentifier, payload.NameIdentifier)); } claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r))); // Rehydrated alongside the roles so the reconstructed principal is what // IDashboardSessionAcl reads on the hub path — a token minted before the tag field // existed (or by a caller with no grant) simply yields an empty grant, which denies. claims.AddRange((payload.Tags ?? []).Select(t => new Claim( DashboardAuthenticationDefaults.DashboardTagClaimType, t))); ClaimsIdentity identity = new( claims, DashboardAuthenticationDefaults.HubAuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role); return new ClaimsPrincipal(identity); } catch (Exception ex) when (ex is CryptographicException or JsonException) { return null; } } private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles, string[]? Tags); }