using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.DataProtection; 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 and /// role claims. 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 { private 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. 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 is deliberately deferred until per-session hub ACLs land, when tokens gain // session binding. internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5); private readonly ITimeLimitedDataProtector _protector; /// Initializes a new instance of the HubTokenService with a data protection provider. /// The data protection provider for token encryption. public HubTokenService(IDataProtectionProvider dataProtection) { ArgumentNullException.ThrowIfNull(dataProtection); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); } /// Issues a bearer token carrying the user's identity and roles. /// 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); HubTokenPayload payload = new( user.Identity?.Name, user.FindFirstValue(ClaimTypes.NameIdentifier), [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]); 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))); 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); }