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.OtOpcUa.Security.Jwt; public sealed class JwtTokenService { /// /// Alias of — the canonical "zb:displayname" claim. /// All read and mint sites inherit the canonical spelling through this constant. /// public const string DisplayNameClaimType = ZbClaimTypes.DisplayName; /// /// Alias of — the canonical "zb:username" claim. /// All read and mint sites inherit the canonical spelling through this constant. /// public const string UsernameClaimType = ZbClaimTypes.Username; /// /// Role claim type used in the JWT payload. /// /// Issued-only / no internal JwtBearer scheme: OtOpcUa uses a single Cookie /// authentication scheme; the JWT is minted by the /auth/token endpoint and /// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no /// AddJwtBearer pipeline in OtOpcUa — the cookie stores the /// directly. Because no internal /// bearer validation path exists, the short "Role" key is intentionally used here rather /// than the long URI; external consumers receive exactly the /// key they expect. /// /// /// If a JwtBearer scheme is ever added: the /// passed to /// AddJwtBearer MUST set RoleClaimType = JwtTokenService.RoleClaimType (and /// NameClaimType = JwtTokenService.UsernameClaimType) so that /// [Authorize(Roles=...)] and ClaimsPrincipal.IsInRole resolve correctly. /// is already wired to do this and MUST be used /// rather than constructing /// ad hoc. /// /// public const string RoleClaimType = "Role"; private readonly JwtOptions _options; private readonly ILogger _logger; /// Initializes a new instance of the JwtTokenService class. /// The JWT options. /// The logger for the service. 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)."); } } /// Issues a JWT token for the specified user with the given roles. /// The display name of the user. /// The username of the user. /// The roles assigned to the user. /// The JWT token string. 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), }; // Role claims use the short RoleClaimType key ("Role") — see the // doc comment for the issued-only rationale and the JwtBearer caveat. 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); } /// Attempts to validate a JWT token and extract the claims principal. /// The JWT token to validate. /// The claims principal extracted from the token, or null if validation failed. /// True if the token is valid; otherwise false. public bool TryValidate(string token, out ClaimsPrincipal? principal) { principal = null; // Delegate to BuildValidationParameters so RoleClaimType/NameClaimType are always in // sync with the mint constants — no risk of this method diverging from the bearer path. var parameters = BuildValidationParameters(); 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 . /// /// Note: is set to /// and is /// set to so that [Authorize(Roles=...)] and /// ClaimsPrincipal.IsInRole resolve against the short role key ("Role") that /// mints — not the JWT-default "role" or "name" keys. This is the /// required pairing whenever a JwtBearer scheme is wired. /// /// 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, // Pair these with the constants used at mint time so role/name resolution is correct // if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale. RoleClaimType = RoleClaimType, NameClaimType = UsernameClaimType, }; }