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. /// /// /// Server-043: this service is registered as a singleton in /// and /// is shared by two consumer scopes: DashboardHubConnectionFactory /// (scoped, per-circuit; calls from the cookie-authenticated /// dashboard) and HubTokenAuthenticationHandler (transient, per-request; /// calls from the SignalR negotiate / connection path). /// 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"; private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30); 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. public string Issue(ClaimsPrincipal user) { 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), TokenLifetime); } /// Validates a token and returns the equivalent claims principal; null when invalid or expired. /// The token string to validate. 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; } // Server-039: reject a token whose payload carries no caller // identity. A null/empty Name AND NameIdentifier would otherwise // produce a principal that satisfies IsAuthenticated and IsInRole // checks without any associated user, because the AuthenticationType // (the HubToken scheme) is non-empty. 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); }