feat(security): JwtTokenService with HS256 + 15-min expiry

This commit is contained in:
Joseph Doherty
2026-05-26 04:35:46 -04:00
parent 567b8cac1d
commit 93316e3431
2 changed files with 118 additions and 0 deletions

View File

@@ -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;
/// <summary>HS256 signing key. Must be at least 32 bytes (256 bits) UTF-8.</summary>
public string SigningKey { get; set; } = string.Empty;
public string Issuer { get; set; } = "otopcua";
public string Audience { get; set; } = "otopcua";
/// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary>
public int ExpiryMinutes { get; set; } = 15;
}

View File

@@ -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<JwtTokenService> _logger;
public JwtTokenService(IOptions<JwtOptions> options, ILogger<JwtTokenService> 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<string> roles)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
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;
}
}
/// <summary>
/// Returns the validation parameters that the JwtBearer middleware should use. Centralised
/// so the bearer pipeline can't drift from <see cref="TryValidate"/>.
/// </summary>
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,
};
}