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.
This commit is contained in:
@@ -23,9 +23,27 @@ public sealed class JwtTokenService
|
||||
public const string UsernameClaimType = ZbClaimTypes.Username;
|
||||
|
||||
/// <summary>
|
||||
/// Role claim type used in the JWT payload. Kept as the short "Role" key for the
|
||||
/// bearer token payload; the cookie-principal uses <see cref="ZbClaimTypes.Role"/>
|
||||
/// (= <see cref="ClaimTypes.Role"/>) for framework role resolution.
|
||||
/// 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";
|
||||
|
||||
@@ -66,6 +84,8 @@ public sealed class JwtTokenService
|
||||
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));
|
||||
|
||||
@@ -86,18 +106,9 @@ public sealed class JwtTokenService
|
||||
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,
|
||||
};
|
||||
// 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
|
||||
{
|
||||
@@ -115,6 +126,14 @@ public sealed class JwtTokenService
|
||||
/// <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()
|
||||
{
|
||||
@@ -126,5 +145,9 @@ public sealed class JwtTokenService
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user