fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
This commit is contained in:
@@ -18,8 +18,7 @@ public class CentralFailoverTests
|
||||
{
|
||||
JwtSigningKey = signingKey,
|
||||
JwtExpiryMinutes = 15,
|
||||
IdleTimeoutMinutes = 30,
|
||||
JwtRefreshThresholdMinutes = 5
|
||||
IdleTimeoutMinutes = 30
|
||||
});
|
||||
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
|
||||
}
|
||||
@@ -124,44 +123,6 @@ public class CentralFailoverTests
|
||||
Assert.NotNull(expClaim);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an idle timeout is detected.</summary>
|
||||
[Fact]
|
||||
public void JwtToken_IdleTimeout_Detected()
|
||||
{
|
||||
var jwtService = CreateJwtService();
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Idle User",
|
||||
username: "idle_user",
|
||||
roles: new[] { "Viewer" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
// Token was just generated — should NOT be idle timed out
|
||||
Assert.False(jwtService.IsIdleTimedOut(principal!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the refresh check detects a token near expiry.</summary>
|
||||
[Fact]
|
||||
public void JwtToken_ShouldRefresh_DetectsNearExpiry()
|
||||
{
|
||||
var jwtService = CreateJwtService();
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "User",
|
||||
username: "user",
|
||||
roles: new[] { "Administrator" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
// Token was just generated with 15min expiry and 5min threshold — NOT near expiry
|
||||
Assert.False(jwtService.ShouldRefresh(principal!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that deployment status for multiple instances is tracked independently.</summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
|
||||
@@ -17,8 +17,7 @@ public class SecurityHardeningTests
|
||||
{
|
||||
JwtSigningKey = signingKey,
|
||||
JwtExpiryMinutes = 15,
|
||||
IdleTimeoutMinutes = 30,
|
||||
JwtRefreshThresholdMinutes = 5
|
||||
IdleTimeoutMinutes = 30
|
||||
});
|
||||
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
|
||||
}
|
||||
@@ -118,7 +117,6 @@ public class SecurityHardeningTests
|
||||
|
||||
Assert.Equal(15, options.JwtExpiryMinutes);
|
||||
Assert.Equal(30, options.IdleTimeoutMinutes);
|
||||
Assert.Equal(5, options.JwtRefreshThresholdMinutes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: jwt Token, tampered Payload, fails Validation.</summary>
|
||||
@@ -151,36 +149,6 @@ public class SecurityHardeningTests
|
||||
Assert.Null(principal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: jwt Refresh Token, preserves Identity.</summary>
|
||||
[Fact]
|
||||
public void JwtRefreshToken_PreservesIdentity()
|
||||
{
|
||||
var jwtService = CreateJwtService();
|
||||
|
||||
var originalToken = jwtService.GenerateToken(
|
||||
displayName: "Original User",
|
||||
username: "orig_user",
|
||||
roles: new[] { "Administrator", "Designer" },
|
||||
permittedSiteIds: new[] { "site-1" });
|
||||
|
||||
var principal = jwtService.ValidateToken(originalToken);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
// Refresh the token
|
||||
var refreshedToken = jwtService.RefreshToken(
|
||||
principal!,
|
||||
new[] { "Administrator", "Designer" },
|
||||
new[] { "site-1" });
|
||||
|
||||
Assert.NotNull(refreshedToken);
|
||||
|
||||
var refreshedPrincipal = jwtService.ValidateToken(refreshedToken!);
|
||||
Assert.NotNull(refreshedPrincipal);
|
||||
|
||||
Assert.Equal("Original User", refreshedPrincipal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
|
||||
Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the case: startup Validator, rejects Insecure Ldap In Production.</summary>
|
||||
[Fact]
|
||||
public void StartupValidator_RejectsInsecureLdapInProduction()
|
||||
|
||||
Reference in New Issue
Block a user