Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
T
Joseph Doherty d0777eee29 fix(auth): OtOpcUa Task 1.5 review — pin JWT role-claim test + document issued-only JWT role key
Fix 1 (test): Token_payload_uses_canonical_zb_claim_keys now asserts that the JWT
payload carries at least one role under JwtTokenService.RoleClaimType ("Role"),
pinning the role-key contract so a future rename is caught immediately. Adds a
comment explaining why alice has roles (appsettings "ReadOnly"→"ConfigViewer"
baseline). Adds missing `using ZB.MOM.WW.OtOpcUa.Security.Jwt` to the test file.

Fix 2 (no-validation path — no AddJwtBearer in production pipeline): grep of src/
confirms no AddJwtBearer / JwtBearer scheme in ServiceCollectionExtensions or Host;
the ServiceCollectionExtensions doc comment explicitly states "no JwtBearer parallel
scheme". RoleClaimType intentionally stays the short "Role" key. Three changes:
  - RoleClaimType doc comment documents issued-only nature, the caveat that a
    JwtBearer scheme MUST use BuildValidationParameters(), and that BuildValidationParameters
    is already wired to set RoleClaimType+NameClaimType correctly.
  - Issue() inline comment at the role-mint site references RoleClaimType docs.
  - BuildValidationParameters() now sets RoleClaimType=RoleClaimType and
    NameClaimType=UsernameClaimType so that if it is ever passed to AddJwtBearer,
    role/name resolution is correct without any extra wiring. TryValidate() is
    refactored to delegate to BuildValidationParameters() so the two can never drift.

All 35 security tests green.
2026-06-02 06:30:10 -04:00

154 lines
7.1 KiB
C#

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
{
/// <summary>
/// Alias of <see cref="ZbClaimTypes.DisplayName"/> — the canonical "zb:displayname" claim.
/// All read and mint sites inherit the canonical spelling through this constant.
/// </summary>
public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
/// <summary>
/// Alias of <see cref="ZbClaimTypes.Username"/> — the canonical "zb:username" claim.
/// All read and mint sites inherit the canonical spelling through this constant.
/// </summary>
public const string UsernameClaimType = ZbClaimTypes.Username;
/// <summary>
/// Role claim type used in the JWT payload.
/// <para>
/// <b>Issued-only / no internal JwtBearer scheme:</b> OtOpcUa uses a single Cookie
/// authentication scheme; the JWT is minted by the <c>/auth/token</c> endpoint and
/// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no
/// <c>AddJwtBearer</c> pipeline in OtOpcUa — the cookie stores the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> directly. Because no internal
/// bearer validation path exists, the short "Role" key is intentionally used here rather
/// than the long <see cref="ClaimTypes.Role"/> URI; external consumers receive exactly the
/// key they expect.
/// </para>
/// <para>
/// <b>If a JwtBearer scheme is ever added:</b> the
/// <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> passed to
/// <c>AddJwtBearer</c> MUST set <c>RoleClaimType = JwtTokenService.RoleClaimType</c> (and
/// <c>NameClaimType = JwtTokenService.UsernameClaimType</c>) so that
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve correctly.
/// <see cref="BuildValidationParameters"/> is already wired to do this and MUST be used
/// rather than constructing <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/>
/// ad hoc.
/// </para>
/// </summary>
public const string RoleClaimType = "Role";
private readonly JwtOptions _options;
private readonly ILogger<JwtTokenService> _logger;
/// <summary>Initializes a new instance of the JwtTokenService class.</summary>
/// <param name="options">The JWT options.</param>
/// <param name="logger">The logger for the service.</param>
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).");
}
}
/// <summary>Issues a JWT token for the specified user with the given roles.</summary>
/// <param name="displayName">The display name of the user.</param>
/// <param name="username">The username of the user.</param>
/// <param name="roles">The roles assigned to the user.</param>
/// <returns>The JWT token string.</returns>
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),
};
// Role claims use the short RoleClaimType key ("Role") — see the <see cref="RoleClaimType"/>
// 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);
}
/// <summary>Attempts to validate a JWT token and extract the claims principal.</summary>
/// <param name="token">The JWT token to validate.</param>
/// <param name="principal">The claims principal extracted from the token, or null if validation failed.</param>
/// <returns>True if the token is valid; otherwise false.</returns>
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;
}
}
/// <summary>
/// Returns the validation parameters that the JwtBearer middleware should use. Centralised
/// so the bearer pipeline can't drift from <see cref="TryValidate"/>.
/// <para>
/// <b>Note:</b> <see cref="TokenValidationParameters.RoleClaimType"/> is set to
/// <see cref="RoleClaimType"/> and <see cref="TokenValidationParameters.NameClaimType"/> is
/// set to <see cref="UsernameClaimType"/> so that <c>[Authorize(Roles=...)]</c> and
/// <c>ClaimsPrincipal.IsInRole</c> resolve against the short role key ("Role") that
/// <see cref="Issue"/> mints — not the JWT-default "role" or "name" keys. This is the
/// required pairing whenever a JwtBearer scheme is wired.
/// </para>
/// </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,
// 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,
};
}