diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
index a240f4b9..89981d9e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
@@ -23,9 +23,27 @@ public sealed class JwtTokenService
public const string UsernameClaimType = ZbClaimTypes.Username;
///
- /// Role claim type used in the JWT payload. Kept as the short "Role" key for the
- /// bearer token payload; the cookie-principal uses
- /// (= ) for framework role resolution.
+ /// 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";
@@ -66,6 +84,8 @@ public sealed class JwtTokenService
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));
@@ -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
///
/// 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()
{
@@ -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,
};
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
index bbd92169..4bcb3452 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
@@ -19,6 +19,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
+using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
@@ -360,11 +361,18 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
///
/// Task 1.5 — JWT payload uses canonical claim keys: after login and token issue the JWT
/// payload segment MUST contain "zb:username" and "zb:displayname" keys (not the old short
- /// "Username"/"DisplayName" strings).
+ /// "Username"/"DisplayName" strings), AND the role claim(s) MUST be carried under the key
+ /// (currently the short "Role" key — intentionally
+ /// NOT the long ClaimTypes.Role URI, because OtOpcUa is JWT-issued-only; see
+ /// docs for the rationale and the caveat that
+ /// applies if a JwtBearer scheme is ever added).
///
[Fact]
public async Task Token_payload_uses_canonical_zb_claim_keys()
{
+ // Arrange — the appsettings baseline maps group "ReadOnly" → role "ConfigViewer", so alice
+ // (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT.
+ // No extra DB rows needed — the appsettings GroupToRole entry is always active.
var client = NewClient();
var loginResp = await client.PostAsJsonAsync("/auth/login",
@@ -391,6 +399,19 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
"JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)");
displayNameEl.GetString().ShouldBe("Alice User");
+ // Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role").
+ // This pins the role-key contract: any future rename of RoleClaimType will be caught here.
+ // The appsettings "ReadOnly" → "ConfigViewer" mapping guarantees alice has ≥1 role.
+ payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue(
+ $"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " +
+ $"(\"{JwtTokenService.RoleClaimType}\")");
+ // The role value may be a string (single) or array (multiple); either way it must be non-empty.
+ if (roleEl.ValueKind == JsonValueKind.Array)
+ roleEl.EnumerateArray().Select(e => e.GetString()).ShouldNotBeEmpty(
+ "JWT role array must contain at least one role value");
+ else
+ roleEl.GetString().ShouldNotBeNullOrEmpty("JWT role value must not be empty");
+
// Old short-name literals must NOT be present.
payloadJson.TryGetProperty("Username", out _).ShouldBeFalse(
"JWT payload must not carry old 'Username' key after Task 1.5 migration");