d0777eee29
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.
154 lines
7.1 KiB
C#
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,
|
|
};
|
|
}
|