feat(auth): OtOpcUa adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
This commit is contained in:
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
@@ -116,12 +117,14 @@ public static class AuthEndpoints
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, result.Username ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, result.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, result.DisplayName ?? username),
|
||||
// ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically.
|
||||
new(ZbClaimTypes.Name, result.Username ?? username),
|
||||
new(ZbClaimTypes.Username, result.Username ?? username),
|
||||
new(ZbClaimTypes.DisplayName, result.DisplayName ?? username),
|
||||
};
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
// ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work.
|
||||
claims.Add(new Claim(ZbClaimTypes.Role, role));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
@@ -157,7 +160,7 @@ public static class AuthEndpoints
|
||||
?? user.Identity?.Name
|
||||
?? string.Empty;
|
||||
var displayName = user.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value ?? username;
|
||||
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
|
||||
var roles = user.FindAll(ZbClaimTypes.Role).Select(c => c.Value).ToArray();
|
||||
|
||||
return Results.Ok(new TokenResponse(jwt.Issue(displayName, username, roles)));
|
||||
}
|
||||
|
||||
@@ -4,13 +4,29 @@ 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
|
||||
{
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
/// <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. 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.
|
||||
/// </summary>
|
||||
public const string RoleClaimType = "Role";
|
||||
|
||||
private readonly JwtOptions _options;
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
@@ -57,28 +58,34 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration /
|
||||
// HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults
|
||||
// in the PostConfigure block below.
|
||||
o.LoginPath = "/login";
|
||||
o.LogoutPath = "/auth/logout";
|
||||
o.Cookie.HttpOnly = true;
|
||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||
});
|
||||
|
||||
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||
// ZbCookieDefaults.Apply sets HttpOnly=true, SameSite=Strict, SlidingExpiration=true,
|
||||
// SecurePolicy, and ExpireTimeSpan; we then set the app-specific cookie name on top.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||
{
|
||||
var v = ourOpts.Value;
|
||||
|
||||
// Apply canonical hardened defaults (HttpOnly, SameSite=Strict, SlidingExpiration,
|
||||
// SecurePolicy, ExpireTimeSpan). Cookie name is NOT touched by ZbCookieDefaults —
|
||||
// we set it below so each app keeps its own distinct cookie name.
|
||||
ZbCookieDefaults.Apply(
|
||||
cookieOpts,
|
||||
requireHttps: v.RequireHttpsCookie,
|
||||
idleTimeout: TimeSpan.FromMinutes(v.ExpiryMinutes));
|
||||
|
||||
// Keep OtOpcUa's own cookie name (default "ZB.MOM.WW.OtOpcUa.Auth").
|
||||
cookieOpts.Cookie.Name = v.Name;
|
||||
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||
cookieOpts.SlidingExpiration = true;
|
||||
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||
? CookieSecurePolicy.Always
|
||||
: CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
if (!v.RequireHttpsCookie)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -11,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -83,6 +86,15 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||
// scheme's challenge heuristic without depending on the full Razor host.
|
||||
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||
// Canonical-claims probe: returns all claim types+values from the cookie
|
||||
// principal so tests can assert the canonical ZbClaimTypes vocabulary.
|
||||
e.MapGet("/auth/whoami", (HttpContext ctx) =>
|
||||
{
|
||||
var claims = ctx.User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
.ToArray();
|
||||
return Results.Ok(claims);
|
||||
}).RequireAuthorization();
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -251,6 +263,20 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Parses the payload segment of a JWT and returns it as a <see cref="JsonElement"/>.</summary>
|
||||
private static JsonElement JwtPayloadJson(string jwt)
|
||||
{
|
||||
var payloadSegment = jwt.Split('.')[1];
|
||||
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
|
||||
padded = (padded.Length % 4) switch
|
||||
{
|
||||
2 => padded + "==",
|
||||
3 => padded + "=",
|
||||
_ => padded,
|
||||
};
|
||||
return JsonDocument.Parse(Convert.FromBase64String(padded)).RootElement;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
|
||||
private static IReadOnlyList<string> JwtRoleClaims(string jwt)
|
||||
{
|
||||
@@ -268,6 +294,110 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
: [roleProp.GetString()!];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated
|
||||
/// principal MUST carry the canonical ZbClaimTypes vocabulary:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ZbClaimTypes.Name"/> (= ClaimTypes.Name) so Identity.Name resolves.</item>
|
||||
/// <item><see cref="ZbClaimTypes.Username"/> (= "zb:username") — login username.</item>
|
||||
/// <item><see cref="ZbClaimTypes.DisplayName"/> (= "zb:displayname") — human-friendly name.</item>
|
||||
/// <item><see cref="ZbClaimTypes.Role"/> (= ClaimTypes.Role) — at least one role claim.</item>
|
||||
/// </list>
|
||||
/// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted
|
||||
/// (the pre-Task-1.5 strings that would indicate the migration was incomplete).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal()
|
||||
{
|
||||
// Arrange — seed a DB role so the mapper produces a role claim.
|
||||
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LdapGroup = "ReadOnly",
|
||||
Role = AdminRole.FleetAdmin,
|
||||
IsSystemWide = true,
|
||||
ClusterId = null,
|
||||
});
|
||||
|
||||
var client = NewClient();
|
||||
|
||||
// Act — login.
|
||||
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResp.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
// Call the whoami probe to read back the cookie principal's claims.
|
||||
var whoamiReq = new HttpRequestMessage(HttpMethod.Get, "/auth/whoami");
|
||||
AttachCookies(whoamiReq, loginResp);
|
||||
var whoamiResp = await client.SendAsync(whoamiReq, Ct);
|
||||
whoamiResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var claims = (await whoamiResp.Content.ReadFromJsonAsync<ClaimDto[]>(Ct))!;
|
||||
|
||||
// Assert — canonical name claim (ClaimTypes.Name URI) so Identity.Name resolves.
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Name && c.Value == "alice",
|
||||
$"Expected {ZbClaimTypes.Name} claim with value 'alice'");
|
||||
|
||||
// Assert — canonical username claim ("zb:username").
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Username && c.Value == "alice",
|
||||
$"Expected {ZbClaimTypes.Username} claim with value 'alice'");
|
||||
|
||||
// Assert — canonical display-name claim ("zb:displayname").
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.DisplayName && c.Value == "Alice User",
|
||||
$"Expected {ZbClaimTypes.DisplayName} claim with value 'Alice User'");
|
||||
|
||||
// Assert — at least one role claim using canonical ZbClaimTypes.Role (= ClaimTypes.Role).
|
||||
claims.ShouldContain(c => c.Type == ZbClaimTypes.Role,
|
||||
$"Expected at least one {ZbClaimTypes.Role} claim");
|
||||
|
||||
// Assert — old pre-Task-1.5 short literals must NOT appear.
|
||||
claims.ShouldNotContain(c => c.Type == "Username",
|
||||
"Old 'Username' literal must not be emitted after Task 1.5 migration");
|
||||
claims.ShouldNotContain(c => c.Type == "DisplayName",
|
||||
"Old 'DisplayName' literal must not be emitted after Task 1.5 migration");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Token_payload_uses_canonical_zb_claim_keys()
|
||||
{
|
||||
var client = NewClient();
|
||||
|
||||
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResp.EnsureSuccessStatusCode();
|
||||
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
AttachCookies(tokenReq, loginResp);
|
||||
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
||||
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
var jwt = payload.GetProperty("token").GetString()!;
|
||||
|
||||
var payloadJson = JwtPayloadJson(jwt);
|
||||
|
||||
// Canonical "zb:username" key must be present.
|
||||
payloadJson.TryGetProperty("zb:username", out var usernameEl).ShouldBeTrue(
|
||||
"JWT payload must carry 'zb:username' claim (canonical ZbClaimTypes.Username)");
|
||||
usernameEl.GetString().ShouldBe("alice");
|
||||
|
||||
// Canonical "zb:displayname" key must be present.
|
||||
payloadJson.TryGetProperty("zb:displayname", out var displayNameEl).ShouldBeTrue(
|
||||
"JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)");
|
||||
displayNameEl.GetString().ShouldBe("Alice User");
|
||||
|
||||
// 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");
|
||||
payloadJson.TryGetProperty("DisplayName", out _).ShouldBeFalse(
|
||||
"JWT payload must not carry old 'DisplayName' key after Task 1.5 migration");
|
||||
}
|
||||
|
||||
/// <summary>Tests that logout clears the cookie.</summary>
|
||||
[Fact]
|
||||
public async Task Logout_clears_the_cookie()
|
||||
@@ -392,4 +522,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserialising the /auth/whoami claim list.
|
||||
/// Must match the anonymous projection in the whoami endpoint.
|
||||
/// </summary>
|
||||
private sealed record ClaimDto(string Type, string Value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user