From 93316e3431f68735c73a0aa6a8f069da1a58e281 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 04:35:46 -0400 Subject: [PATCH] feat(security): JwtTokenService with HS256 + 15-min expiry --- .../Jwt/JwtOptions.cs | 16 +++ .../Jwt/JwtTokenService.cs | 102 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs new file mode 100644 index 0000000..9541be8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.OtOpcUa.Security.Jwt; + +public sealed class JwtOptions +{ + public const string SectionName = "Security:Jwt"; + public const int MinSigningKeyBytes = 32; + + /// HS256 signing key. Must be at least 32 bytes (256 bits) UTF-8. + public string SigningKey { get; set; } = string.Empty; + + public string Issuer { get; set; } = "otopcua"; + public string Audience { get; set; } = "otopcua"; + + /// Default token expiry. Mirrors ScadaLink (15 min). + public int ExpiryMinutes { get; set; } = 15; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs new file mode 100644 index 0000000..499046b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs @@ -0,0 +1,102 @@ +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 ZB.MOM.WW.OtOpcUa.Security.Jwt; + +public sealed class JwtTokenService +{ + public const string DisplayNameClaimType = "DisplayName"; + public const string UsernameClaimType = "Username"; + public const string RoleClaimType = "Role"; + + private readonly JwtOptions _options; + private readonly ILogger _logger; + + public JwtTokenService(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + + var keyByteLength = string.IsNullOrEmpty(_options.SigningKey) + ? 0 + : Encoding.UTF8.GetByteCount(_options.SigningKey); + if (keyByteLength < JwtOptions.MinSigningKeyBytes) + { + throw new InvalidOperationException( + $"JwtOptions.SigningKey must be at least {JwtOptions.MinSigningKeyBytes} bytes " + + $"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s)."); + } + } + + public string Issue(string displayName, string username, IReadOnlyList roles) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(DisplayNameClaimType, displayName), + new(UsernameClaimType, username), + }; + foreach (var role in roles) + claims.Add(new Claim(RoleClaimType, role)); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_options.ExpiryMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public bool TryValidate(string token, out ClaimsPrincipal? principal) + { + principal = null; + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var parameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = _options.Issuer, + ValidateAudience = true, + ValidAudience = _options.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.Zero, + }; + + try + { + var handler = new JwtSecurityTokenHandler(); + principal = handler.ValidateToken(token, parameters, out _); + return true; + } + catch (Exception ex) when (ex is SecurityTokenException or ArgumentException) + { + _logger.LogDebug(ex, "JWT validation failed"); + return false; + } + } + + /// + /// Returns the validation parameters that the JwtBearer middleware should use. Centralised + /// so the bearer pipeline can't drift from . + /// + public TokenValidationParameters BuildValidationParameters() => new() + { + ValidateIssuer = true, + ValidIssuer = _options.Issuer, + ValidateAudience = true, + ValidAudience = _options.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)), + ClockSkew = TimeSpan.Zero, + }; +}