using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using ZB.MOM.WW.Auth.AspNetCore; namespace ZB.MOM.WW.ScadaBridge.Security; public class JwtTokenService { private readonly SecurityOptions _options; private readonly ILogger _logger; // Task 1.5 (full canonical claims): these constants are the single source of // truth that every mint site, every authorization policy, and the token // validation parameters reference. Redefining them as aliases of the shared // ZbClaimTypes vocabulary migrates the whole module centrally: // - Role → the framework URI (ClaimTypes.Role) so [Authorize(Roles=…)], // IsInRole, AND RequireClaim(RoleClaimType,…) all resolve. // - SiteId → ZbClaimTypes.ScopeId ("zb:scopeid") — the canonical scope claim. // - DisplayName/Username → the canonical "zb:" strings. // LastActivity has no canonical equivalent (it is a ScadaBridge-internal // idle-timeout anchor), so it keeps its existing literal. public const string DisplayNameClaimType = ZbClaimTypes.DisplayName; public const string UsernameClaimType = ZbClaimTypes.Username; public const string RoleClaimType = ZbClaimTypes.Role; public const string SiteIdClaimType = ZbClaimTypes.ScopeId; public const string LastActivityClaimType = "LastActivity"; /// /// Fixed issuer bound into every token and required on validation. Binding /// issuer/audience is defence-in-depth: even though the HMAC key is shared only /// between the two central nodes, accidental reuse of the same secret for an /// unrelated internal token would otherwise be silently exploitable. /// public const string TokenIssuer = "scadabridge-central"; /// Fixed audience bound into every token and required on validation. public const string TokenAudience = "scadabridge-central"; /// Initializes the service and validates the JWT signing key meets the minimum length requirement. /// Security options containing the JWT signing key, expiry, and timeout settings. /// Logger instance. public JwtTokenService(IOptions options, ILogger logger) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Fail fast: a missing or short signing key produces trivially forgeable tokens. // HMAC-SHA256 requires a key of at least 256 bits (32 bytes). var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey) ? 0 : Encoding.UTF8.GetByteCount(_options.JwtSigningKey); if (keyByteLength < SecurityOptions.MinJwtSigningKeyBytes) { throw new InvalidOperationException( $"SecurityOptions.JwtSigningKey must be at least {SecurityOptions.MinJwtSigningKeyBytes} bytes " + $"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s). " + "Configure a strong signing key before starting the service."); } } /// /// Issues a fresh JWT. sets the idle-timeout /// anchor; when omitted (a brand-new login) it defaults to now. On a token /// refresh the caller MUST pass the existing anchor forward so the idle window /// continues to be measured from the user's last genuine activity rather than /// from token issuance time. /// /// Human-readable display name embedded as a claim. /// Authenticated username embedded as a claim. /// Role names to embed as claims. /// Site identifiers the user may deploy to; null for system-wide access. /// Idle-timeout anchor; defaults to now when null (fresh login). /// The signed JWT string, ready to embed in a cookie or bearer header. public string GenerateToken( string displayName, string username, IReadOnlyList roles, IReadOnlyList? permittedSiteIds, DateTimeOffset? lastActivity = null) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List { new(DisplayNameClaimType, displayName), new(UsernameClaimType, username), new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o")) }; foreach (var role in roles) { claims.Add(new Claim(RoleClaimType, role)); } if (permittedSiteIds != null) { foreach (var siteId in permittedSiteIds) { claims.Add(new Claim(SiteIdClaimType, siteId)); } } var token = new JwtSecurityToken( issuer: TokenIssuer, audience: TokenAudience, claims: claims, expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes), signingCredentials: credentials); // MapOutboundClaims=false: write the claim TYPE strings into the JWT // verbatim (e.g. the ClaimTypes.Role URI for RoleClaimType) rather than // letting JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap rewrite the // framework URI to a short JWT name ("role"). Paired with // MapInboundClaims=false on validation (see ValidateToken), this makes the // claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte // the same string both in the token and after it is read back, with no // mapping round-trip surprises. The "zb:" scope/display/username claims are // not in any map and were unaffected either way; pinning both directions // makes the role/site migration to canonical types deterministic. var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; handler.OutboundClaimTypeMap.Clear(); return handler.WriteToken(token); } /// Validates a JWT string and returns the decoded , or null if the token is invalid or expired. /// The JWT string to validate. /// The decoded principal, or null if validation fails. public ClaimsPrincipal? ValidateToken(string token) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TokenIssuer, ValidateAudience = true, ValidAudience = TokenAudience, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = key, ClockSkew = TimeSpan.Zero, // The token was minted carrying the canonical claim TYPE strings // verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType = // ZbClaimTypes.ScopeId, etc.). Pin the same role/name claim types here // so the validated principal's Identity.Name and IsInRole resolve, and // so RequireClaim(RoleClaimType, …) sees exactly the type that is in the // token. RoleClaimType = RoleClaimType, NameClaimType = ZbClaimTypes.Name }; try { // MapInboundClaims=false: do NOT let JwtSecurityTokenHandler rewrite // inbound claim types via DefaultInboundClaimTypeMap. The token already // holds the canonical type strings (see GenerateToken's // MapOutboundClaims=false), so reading them back unmapped yields exactly // the strings every policy + claim helper expects. var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; var principal = handler.ValidateToken(token, validationParameters, out _); return principal; } catch (Exception ex) when (ex is SecurityTokenException or ArgumentException) { _logger.LogDebug(ex, "Token validation failed"); return null; } } /// Returns true if the token carried by is within the sliding-refresh threshold and should be reissued. /// The current authenticated principal. /// true when the remaining token lifetime falls below . public bool ShouldRefresh(ClaimsPrincipal principal) { var expClaim = principal.FindFirst("exp"); if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix)) return false; var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix); var remaining = expiry - DateTimeOffset.UtcNow; return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes; } /// Returns true if the session has exceeded the idle timeout based on the claim. /// The current authenticated principal. /// true when the elapsed time since last activity exceeds . public bool IsIdleTimedOut(ClaimsPrincipal principal) { var lastActivityClaim = principal.FindFirst(LastActivityClaimType); if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity)) return true; return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes; } /// /// Issues a fresh token (new expiry, re-queried roles) while preserving the /// existing anchor. A refresh is itself /// triggered by a request, but it must not be treated as user activity — the /// idle window must keep being measured from the user's last genuine interaction, /// otherwise the documented 30-minute idle timeout could never fire for a client /// that polls in the background. Call to advance the /// anchor when handling a genuine user request. /// /// A principal that is already past the idle timeout cannot be refreshed: this /// method returns null (the same "cannot refresh" signal it uses for missing /// claims). Enforcing the idle check here — rather than relying on the caller to /// invoke first — guarantees the documented 30-minute /// idle policy holds regardless of caller discipline; otherwise an idle-expired /// session could be kept alive indefinitely by background refreshes (Security-014). /// /// /// The current authenticated principal whose claims carry identity and the last-activity anchor. /// Re-queried role list to embed in the new token. /// Site identifiers the user may deploy to; null for system-wide access. /// A new token string, or null if the session is idle-expired or claims are missing. public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) { var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; if (displayName == null || username == null) { _logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims"); return null; } // An idle-expired session must not be renewed — the user must re-login. if (IsIdleTimedOut(currentPrincipal)) { _logger.LogInformation( "Cannot refresh token for {Username}: session is past the idle timeout", username); return null; } return GenerateToken(displayName, username, currentRoles, permittedSiteIds, ReadLastActivity(currentPrincipal)); } /// /// Issues a fresh token whose anchor is /// advanced to now. This is the explicit "user did something" path — distinct /// from — to be called by the request pipeline when /// handling a genuine user interaction. /// /// The current authenticated principal. /// Re-queried role list to embed in the new token. /// Site identifiers the user may deploy to; null for system-wide access. /// A new token string with an updated last-activity anchor, or null if claims are missing. public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) { var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; if (displayName == null || username == null) { _logger.LogWarning("Cannot record activity: missing DisplayName or Username claims"); return null; } return GenerateToken(displayName, username, currentRoles, permittedSiteIds, DateTimeOffset.UtcNow); } private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal) { var claim = principal.FindFirst(LastActivityClaimType); return claim != null && DateTimeOffset.TryParse(claim.Value, out var value) ? value : null; } }