using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; namespace ScadaLink.Security; public class JwtTokenService { private readonly SecurityOptions _options; private readonly ILogger _logger; public const string DisplayNameClaimType = "DisplayName"; public const string UsernameClaimType = "Username"; public const string RoleClaimType = "Role"; public const string SiteIdClaimType = "SiteId"; 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 = "scadalink-central"; /// Fixed audience bound into every token and required on validation. public const string TokenAudience = "scadalink-central"; 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. /// 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); return new JwtSecurityTokenHandler().WriteToken(token); } 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 }; try { var handler = new JwtSecurityTokenHandler(); 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; } } 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; } 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). /// /// 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. /// 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; } }