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,
};
}