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:
Joseph Doherty
2026-10-22 06:38:44 -04:00
parent f0de11c446
commit 1b3928a0a2
24 changed files with 1728 additions and 611 deletions
@@ -110,8 +110,7 @@ public class JwtTokenServiceTests
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateService(SecurityOptions? options = null)
@@ -145,7 +144,8 @@ public class JwtTokenServiceTests
Assert.Contains("1", siteIds);
Assert.Contains("2", siteIds);
Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
// Bearer tokens carry no idle anchor — the idle window is a cookie-session concept.
Assert.Null(principal.FindFirst(JwtTokenService.LastActivityClaimType));
}
/// <summary>Verifies the case: generate Token, null Site Ids, no Site Id Claims.</summary>
@@ -180,8 +180,7 @@ public class JwtTokenServiceTests
{
JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
});
var result = service2.ValidateToken(token);
Assert.Null(result);
@@ -201,81 +200,6 @@ public class JwtTokenServiceTests
Assert.Contains("HS256", headerJson);
}
/// <summary>Verifies the case: should Refresh, token Near Expiry, returns True.</summary>
[Fact]
public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
{
var options = CreateOptions();
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
var service = CreateService(options);
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.True(service.ShouldRefresh(principal!));
}
/// <summary>Verifies the case: should Refresh, token Far From Expiry, returns False.</summary>
[Fact]
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
{
var service = CreateService(); // 15 min expiry, 5 min threshold
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.False(service.ShouldRefresh(principal!));
}
/// <summary>Verifies the case: is Idle Timed Out, recent Activity, returns False.</summary>
[Fact]
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
{
var service = CreateService();
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(token);
Assert.False(service.IsIdleTimedOut(principal!));
}
/// <summary>Verifies the case: is Idle Timed Out, no Last Activity Claim, returns True.</summary>
[Fact]
public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
{
var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User")
}));
Assert.True(service.IsIdleTimedOut(principal));
}
/// <summary>Verifies the case: refresh Token, returns New Token With Updated Claims.</summary>
[Fact]
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
{
var service = CreateService();
var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
var principal = service.ValidateToken(originalToken);
var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" });
Assert.NotNull(newToken);
var newPrincipal = service.ValidateToken(newToken!);
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Contains("Designer", roles);
}
/// <summary>Verifies the case: refresh Token, missing Claims, returns Null.</summary>
[Fact]
public void RefreshToken_MissingClaims_ReturnsNull()
{
var service = CreateService();
var principal = new ClaimsPrincipal(new ClaimsIdentity());
var result = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(result);
}
}
#endregion
@@ -441,8 +365,7 @@ public class SecurityReviewRegressionTests
{
JwtSigningKey = signingKey,
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
/// <summary>Verifies the case: jwt Token Service, empty Signing Key, throws At Construction.</summary>
@@ -716,8 +639,7 @@ public class SecurityReviewRegressionTests2
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
@@ -768,87 +690,6 @@ public class SecurityReviewRegressionTests2
Assert.NotNull(service.ValidateToken(token));
}
// --- refresh must preserve the original LastActivity timestamp ---
/// <summary>Verifies the case: refresh Token, preserves Original Last Activity Claim.</summary>
[Fact]
public void RefreshToken_PreservesOriginalLastActivityClaim()
{
var service = CreateJwtService();
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
// 30-minute idle window). Refresh must NOT move it forward to "now".
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed);
var refreshedPrincipal = service.ValidateToken(refreshed!);
Assert.NotNull(refreshedPrincipal);
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.NotNull(claim);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
// The carried timestamp must equal the original, not "now".
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
}
/// <summary>Verifies the case: refresh Token, does Not Reset Idle Timeout When User Is Actually Idle.</summary>
[Fact]
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
{
// A user idle for 25 of the 30-minute window: a refresh fired by some background
// request must not make IsIdleTimedOut flip back to false forever.
var service = CreateJwtService();
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
var refreshedPrincipal = service.ValidateToken(refreshed!);
// Still 25 min idle after refresh — not reset to 0.
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
"Refresh wrongly reset the idle clock to ~now");
}
/// <summary>Verifies the case: record Activity, updates Last Activity To Now.</summary>
[Fact]
public void RecordActivity_UpdatesLastActivityToNow()
{
// Genuine user activity (a real request) — distinct from a token refresh —
// updates LastActivity to the current time.
var service = CreateJwtService();
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
}, "test"));
var touched = service.RecordActivity(principal, new[] { "Administrator" }, null);
Assert.NotNull(touched);
var touchedPrincipal = service.ValidateToken(touched!);
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
"RecordActivity should set LastActivity to ~now");
}
}
#endregion
@@ -917,98 +758,6 @@ public class SecurityReviewRegressionTests3
#endregion
#region Code Review Regression Tests — Security-012/013/014/015
/// <summary>
/// Regression tests for <c>RefreshToken</c> re-issuing a token without
/// checking the idle timeout, so an idle-expired session can be renewed indefinitely).
/// </summary>
/// <remarks>
/// Cutover: the former <c>ExtractFirstRdnValue</c> escaped-comma
/// parsing) and username trim before use cases tested ScadaBridge's
/// bespoke <c>LdapAuthService.ExtractFirstRdnValue</c> / <c>NormalizeUsername</c> /
/// <c>BuildFallbackUserDn</c> statics. Those behaviours are now owned by the shared
/// library and covered by its own tests — <c>LdapEscapingTests.FirstRdnValue_IsEscapeAware</c>
/// (escaped-comma RDN extraction) and <c>LdapAuthServiceFailureTests</c>
/// (whitespace-username → BadCredentials, no connection) — so they were removed here.
/// </remarks>
public class SecurityReviewRegressionTests4
{
private static SecurityOptions JwtOptions() => new()
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
};
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
// --- RefreshToken must reject an idle-expired principal ---
/// <summary>Verifies the case: refresh Token, idle Expired Principal, returns Null.</summary>
[Fact]
public void RefreshToken_IdleExpiredPrincipal_ReturnsNull()
{
// A user idle for 31 minutes (past the 30-minute idle window). Even though the
// DisplayName/Username claims are present, the refresh must be refused so an
// idle-expired session cannot be renewed by a background request.
var service = CreateJwtService();
var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed);
}
/// <summary>Verifies the case: refresh Token, active Session Within Idle Window, still Refreshes.</summary>
[Fact]
public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes()
{
// A user idle for 10 minutes (well inside the 30-minute window): refresh must
// still succeed — the idle-timeout guard must not break the normal sliding path.
var service = CreateJwtService();
var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10);
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user"),
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.NotNull(refreshed);
}
/// <summary>Verifies the case: refresh Token, missing Last Activity Claim, returns Null.</summary>
[Fact]
public void RefreshToken_MissingLastActivityClaim_ReturnsNull()
{
// No LastActivity claim — IsIdleTimedOut treats this as timed out, so the
// refresh must be refused rather than minting a fresh session out of nothing.
var service = CreateJwtService();
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
new Claim(JwtTokenService.UsernameClaimType, "user")
}, "test"));
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
Assert.Null(refreshed);
}
}
#endregion
#region Code Review Regression Tests — Security-012 (partial LDAP outage)
/// <summary>
@@ -1580,8 +1329,7 @@ public class CanonicalClaimVocabularyTests
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
JwtExpiryMinutes = 15,
IdleTimeoutMinutes = 30,
JwtRefreshThresholdMinutes = 5
IdleTimeoutMinutes = 30
};
private static JwtTokenService CreateService() =>