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
@@ -6,9 +6,11 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
@@ -18,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// </summary>
public static class AuthEndpoints
{
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/logout</c>, and <c>/auth/ping</c> endpoints on the given route builder.</summary>
/// <summary>Registers the <c>/auth/login</c>, <c>/auth/token</c>, <c>/auth/logout</c>, <c>/auth/ping</c> and <c>/auth/activity</c> endpoints on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoints to.</param>
/// <returns>The same <paramref name="endpoints"/> instance, for call chaining.</returns>
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
@@ -118,70 +120,9 @@ public static class AuthEndpoints
context.Response.Redirect("/");
});
endpoints.MapPost("/auth/token", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429);
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
statusCode: 401);
}
throttle.RecordSuccess(username, remoteIp!);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
// /auth/login. Fall back to no site-scope rather than throwing if a future
// mapper leaves Scope null or sets a different type.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
var token = jwtService.GenerateToken(
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}).DisableAntiforgery();
// Credential → bearer-token exchange for the CLI (review 2026-10-21 05/U1, 05/P1).
// Antiforgery is disabled: this is a non-cookie, credential-in-body endpoint.
endpoints.MapPost("/auth/token", (Delegate)HandleTokenAsync).DisableAntiforgery();
// Logout is a state-changing authenticated action: it
// keeps antiforgery validation enabled so it cannot be triggered
@@ -212,6 +153,107 @@ public static class AuthEndpoints
return endpoints;
}
/// <summary>
/// Handler for <c>POST /auth/token</c>: binds the form-posted <c>username</c>/<c>password</c>
/// against LDAP (login-throttled), maps the groups to roles and site scope, and returns a
/// short-lived HS256 bearer JWT (<see cref="SecurityOptions.JwtExpiryMinutes"/>) that
/// <c>ManagementAuthenticator</c> accepts on <c>POST /management</c>, the audit REST
/// endpoints and the debug-stream hub. The token carries the username, display name, roles
/// and permitted site ids — everything those surfaces need, so a bearer request costs no
/// LDAP bind. The response's <c>management_bearer: true</c> marks a server whose management
/// surfaces accept the token; a client that does not see it (an older server, whose tokens
/// nothing accepted) falls back to HTTP Basic.
/// </summary>
/// <remarks>
/// Under the dev/test <c>DisableLogin</c> flag the exchange answers <c>404</c>: the
/// management surfaces already authenticate every request as the dev user, so the CLI falls
/// back to Basic, and no credential-free token is ever minted that another deployment
/// sharing the signing key might honour.
/// </remarks>
/// <param name="context">The current HTTP context.</param>
/// <returns>The token JSON on success; 400/401/404/429 otherwise.</returns>
public static async Task<IResult> HandleTokenAsync(HttpContext context)
{
var disableLogin = context.RequestServices.GetService<IOptions<AuthDisableLoginOptions>>()?.Value;
if (disableLogin?.DisableLogin == true)
{
return Results.Json(
new { error = "Token exchange is unavailable while login is disabled.", code = "TOKEN_EXCHANGE_DISABLED" },
statusCode: StatusCodes.Status404NotFound);
}
if (!context.Request.HasFormContentType)
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
var form = await context.Request.ReadFormAsync(context.RequestAborted);
var username = form["username"].ToString();
var password = form["password"].ToString();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
}
// Login throttle: refuse a bind while this username+IP key is locked out
// (fixed-window password-spray guard, arch-review P1/UA5).
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp!))
{
return Results.Json(
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
statusCode: 429);
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
throttle.RecordFailure(username, remoteIp!);
return Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
statusCode: 401);
}
throttle.RecordSuccess(username, remoteIp!);
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
// Guard the opaque-Scope unwrap (review I4); see the matching note on
// /auth/login. Fall back to no site-scope rather than throwing if a future
// mapper leaves Scope null or sets a different type.
var scope = roleMapping.Scope is RoleMappingResult mapped
? mapped
: new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false);
var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName;
var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username;
// Site ids are stamped exactly as ManagementAuthenticator's Basic path projects them:
// none for a system-wide grant, the permitted set otherwise.
var token = jwtService.GenerateToken(
displayName,
resolvedUsername,
roleMapping.Roles,
scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds);
return Results.Json(new
{
access_token = token,
token_type = "Bearer",
expires_in = (int)jwtService.TokenLifetime.TotalSeconds,
management_bearer = true,
username = resolvedUsername,
display_name = displayName,
roles = roleMapping.Roles,
});
}
/// <summary>
/// Handler for <c>GET /auth/ping</c>. Returns <c>200</c> while the caller's
/// cookie session is still valid and <c>401</c> once it has lapsed