feat(auth): ScadaBridge full canonical claims (ZbClaimTypes role/scope) + ZbCookieDefaults, keep cookie name (Task 1.5)

This commit is contained in:
Joseph Doherty
2026-06-02 06:23:15 -04:00
parent afa55981d5
commit a0938f708b
25 changed files with 247 additions and 50 deletions
@@ -1205,3 +1205,136 @@ public class SecurityOptionsValidatorTests
}
#endregion
#region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) role/scope migration
/// <summary>
/// Task 1.5 (full canonical adoption): the Security module's claim-type constants now
/// alias the shared <see cref="ZbClaimTypes"/> vocabulary. These tests pin the contract
/// that the MINTED claim type is exactly the type the policies + token validation CONSUME,
/// across both the JWT bearer and the cookie principal, so a future drift fails loudly.
/// </summary>
public class CanonicalClaimVocabularyTests
{
private static SecurityOptions CreateOptions() => new()
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
};
private static JwtTokenService CreateService() =>
new(Options.Create(CreateOptions()), NullLogger<JwtTokenService>.Instance);
[Fact]
public void ClaimTypeConstants_AreCanonicalZbClaimTypes()
{
// The constants must be the canonical shared types so every mint/consume site
// inherits them centrally.
Assert.Equal(ZbClaimTypes.Role, JwtTokenService.RoleClaimType);
Assert.Equal(ZbClaimTypes.ScopeId, JwtTokenService.SiteIdClaimType);
Assert.Equal(ZbClaimTypes.DisplayName, JwtTokenService.DisplayNameClaimType);
Assert.Equal(ZbClaimTypes.Username, JwtTokenService.UsernameClaimType);
// Role aliases the framework URI; scope/display/username are the zb: strings.
Assert.Equal(ClaimTypes.Role, JwtTokenService.RoleClaimType);
Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType);
Assert.Equal("zb:displayname", JwtTokenService.DisplayNameClaimType);
Assert.Equal("zb:username", JwtTokenService.UsernameClaimType);
// LastActivity has no canonical equivalent and is unchanged.
Assert.Equal("LastActivity", JwtTokenService.LastActivityClaimType);
}
[Fact]
public void MintedJwt_RoundTrips_CanonicalClaimTypesVerbatim()
{
var service = CreateService();
var token = service.GenerateToken(
"Jane Roe", "janer",
new[] { Roles.Audit },
new[] { "7" });
var principal = service.ValidateToken(token);
Assert.NotNull(principal);
// Claim TYPES must be the canonical strings, not the framework's short JWT names
// (MapInboundClaims=false / cleared outbound map guarantee this).
Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value);
Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value);
Assert.Equal(Roles.Audit, principal.FindFirst(ZbClaimTypes.Role)?.Value);
Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value);
}
[Fact]
public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy()
{
// The load-bearing round-trip: a JWT minted with RoleClaimType=Audit must satisfy
// a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation.
var service = CreateService();
var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Audit }, null);
var principal = service.ValidateToken(token);
Assert.NotNull(principal);
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!));
// Audit does NOT grant AuditExport via a different vocabulary by accident:
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!));
// AuditReadOnly is read-only — separate assertion that the role VALUE semantics
// are untouched by the type migration.
var roPrincipal = service.ValidateToken(
service.GenerateToken("RO", "ro", new[] { Roles.AuditReadOnly }, null));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!));
}
[Fact]
public async Task CookiePrincipal_BuiltLikeLogin_AuthorizesAndExposesCanonicalTypes()
{
// Reproduce AuthEndpoints' cookie principal exactly: canonical claim types via the
// constants + a ClaimsIdentity whose roleType is RoleClaimType so IsInRole resolves.
var claims = new List<Claim>
{
new(ClaimTypes.Name, "janer"),
new(JwtTokenService.DisplayNameClaimType, "Jane Roe"),
new(JwtTokenService.UsernameClaimType, "janer"),
new(JwtTokenService.RoleClaimType, Roles.Admin),
new(JwtTokenService.SiteIdClaimType, "3"),
};
var identity = new ClaimsIdentity(
claims,
authenticationType: "TestCookie",
nameType: ClaimTypes.Name,
roleType: JwtTokenService.RoleClaimType);
var principal = new ClaimsPrincipal(identity);
// Claim types are canonical.
Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType);
Assert.Equal("3", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value);
Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
// roleType wiring => IsInRole resolves against the canonical role claim.
Assert.True(principal.IsInRole(Roles.Admin));
// Admin holds every permission by convention.
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
{
var services = new ServiceCollection();
services.AddScadaBridgeAuthorization();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
var result = await authService.AuthorizeAsync(principal, null, policyName);
return result.Succeeded;
}
}
#endregion