Commons (third-party dep, 7 namespaces, retired ApiKey, repo SaveChanges carve-out), ConfigurationDatabase (5 persisted + 1 non-persisted computed col), ClusterInfrastructure (abbreviated HOCON note, RemotingPort default), Host (component matrix: CI/HealthMonitoring/ExternalSystemGateway have no actors; DeadLetterMonitorActor runs on both roles), Security (Bearer not X-API-Key; ApiKeyAdmin registered by Host), Communication (Task.Run/Sender).
18 KiB
Security & Auth
The Security & Auth component handles user authentication against an LDAP/Active Directory server and enforces role-based authorization across all central cluster operations. It owns the cookie+JWT hybrid session model, the LDAP-group-to-role mapping pipeline, site-scoped deployment permissions, and the inbound API key management seam.
Overview
Security & Auth (#10) runs exclusively on the central cluster — sites have no user-facing interface and perform no independent authentication. The component code lives in src/ZB.MOM.WW.ScadaBridge.Security/, which is a component library (it accepts no IConfiguration directly; wiring is Options-pattern only). The Host composition root calls AddZbLdapAuth with the ScadaBridge:Security:Ldap section before calling AddSecurity, because the shared LDAP service is config-coupled and the component library is not allowed to bind configuration itself.
The component registers:
JwtTokenService— token generation, validation, idle-timeout enforcement, and sliding refresh logic.RoleMapper— DB-backed LDAP-group-to-role resolution with site-scope union semantics.ScadaBridgeGroupRoleMapper— adapter exposingRoleMapperon the sharedIGroupRoleMapper<string>seam.HttpAuditActorAccessor— resolves the authenticated username from the ambient HTTP context for auditActorstamping.- ASP.NET Core cookie authentication (sliding idle window, HttpOnly/Secure defaults via
ZbCookieDefaults.Apply). - Authorization policies (
RequireAdmin,RequireDesign,RequireDeployment,OperationalAudit,AuditExport).
Key Concepts
Direct LDAP bind
Authentication uses a direct username/password bind against the LDAP/AD server via the shared ILdapAuthService (ZB.MOM.WW.Auth.Ldap). The flow is: service-account bind → search for the user entry by username → user-credential bind → group-membership query. The app never caches credentials locally. LDAPS (port 636) or StartTLS is required for production; the AllowInsecure flag in LdapOptions gates unencrypted use to explicitly opted-in deployments (local dev only). No Kerberos/NTLM path exists.
LDAP failure behavior is fail-closed at the login boundary and fail-open at the session boundary: a new login fails immediately if the directory is unreachable; an active session (valid cookie+JWT) continues with its current claims until the JWT expires. This avoids disrupting engineers mid-work during a brief directory outage. When the directory recovers, the next token refresh re-queries groups and issues a fresh token.
Cookie+JWT hybrid session
On successful login the server mints a JWT via JwtTokenService.GenerateToken and writes it into an HttpOnly/Secure authentication cookie. The cookie is the transport — not a bearer header — because Blazor Server's persistent SignalR circuits do not carry Authorization headers on reconnect. The browser sends the cookie on every HTTP and SignalR request automatically.
The JWT is signed with HMAC-SHA256 using a shared symmetric key (JwtSigningKey). Both central nodes share the same key, so either node can issue and validate tokens without a shared session store; the load balancer needs no sticky-session configuration. ClockSkew is set to TimeSpan.Zero to close the standard five-minute tolerance window.
Claims embedded in every token:
| Claim type | Constant | Value |
|---|---|---|
ZbClaimTypes.DisplayName |
JwtTokenService.DisplayNameClaimType |
Human-readable display name |
ZbClaimTypes.Username |
JwtTokenService.UsernameClaimType |
Authenticated username |
ClaimTypes.Role (URI) |
JwtTokenService.RoleClaimType |
One claim per role |
ZbClaimTypes.ScopeId |
JwtTokenService.SiteIdClaimType |
One claim per permitted site (Deployer only) |
"LastActivity" |
JwtTokenService.LastActivityClaimType |
ISO 8601 idle-timeout anchor |
MapOutboundClaims is cleared on the mint path (GenerateToken) so JwtSecurityTokenHandler writes claim type strings verbatim into the token. On the validate path (ValidateToken) only MapInboundClaims = false is set — the outbound map is not touched. Together these settings prevent the default claim-type rewrite maps from transforming the canonical role URI or zb: claim types, keeping the type strings byte-for-byte identical in the token and in every policy check.
Token lifecycle and idle timeout
The JWT lifetime (JwtExpiryMinutes, default 15 minutes) and the cookie idle window (IdleTimeoutMinutes, default 30 minutes) are separate layers. ASP.NET cookie auth's SlidingExpiration = true with ExpireTimeSpan = IdleTimeout models the idle window: the middleware re-issues the cookie once the session passes its halfway mark, keeping active users signed in. The JWT within that cookie has its own 15-minute expiry.
JwtTokenService.ShouldRefresh checks whether remaining JWT lifetime is below JwtRefreshThresholdMinutes (default 5 minutes). RefreshToken issues a fresh JWT while preserving the existing LastActivity anchor — a background refresh is not treated as user activity. RecordActivity advances the anchor to now. IsIdleTimedOut checks whether the elapsed time since LastActivity exceeds IdleTimeoutMinutes; RefreshToken enforces the idle check internally so an idle-expired session cannot be kept alive by background polling regardless of caller discipline (Security-014).
Architecture
Registration split between Host and component
AddSecurity (component library) registers everything except the LDAP service itself:
public static IServiceCollection AddSecurity(this IServiceCollection services)
{
services.AddScoped<JwtTokenService>();
services.AddScoped<RoleMapper>();
services.AddHttpContextAccessor();
services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
});
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
{
ZbCookieDefaults.Apply(
cookieOptions,
requireHttps: securityOptions.Value.RequireHttpsCookie,
idleTimeout: TimeSpan.FromMinutes(securityOptions.Value.IdleTimeoutMinutes));
var cookieName = securityOptions.Value.CookieName;
cookieOptions.Cookie.Name = string.IsNullOrWhiteSpace(cookieName)
? SecurityOptions.DefaultCookieName
: cookieName;
});
services.AddScadaBridgeAuthorization();
return services;
}
The Host composition root calls AddZbLdapAuth(configuration, LdapSectionPath) before AddSecurity(). AddZbLdapAuth registers ILdapAuthService as a singleton, binds LdapOptions from ScadaBridge:Security:Ldap, and registers IValidateOptions<LdapOptions> with ValidateOnStart so a misconfigured directory fails at boot rather than at first login. The Host also registers LibraryInboundApiKeyAdmin as the IInboundApiKeyAdmin singleton via AddSingleton — this is not done by AddSecurity.
Role mapping and site scoping
RoleMapper.MapGroupsToRolesAsync loads all LdapGroupMapping rows from the database, matches the supplied LDAP group names (case-insensitive), and accumulates roles. For the Deployer role it also loads associated SiteScopeRule rows — each row carries a SiteId limiting that mapping to one site. Union semantics (Security-016): if any matched Deployer mapping has no scope rules, the result is system-wide and all accumulated site IDs are discarded:
var isSystemWide = hasUnscopedDeploymentMapping
|| (hasDeploymentRole && !hasScopedDeploymentMapping);
if (isSystemWide)
{
permittedSiteIds.Clear();
}
return new RoleMappingResult(
matchedRoles.ToList(),
permittedSiteIds.ToList(),
isSystemWide);
ScadaBridgeGroupRoleMapper adapts RoleMappingResult onto the shared IGroupRoleMapper<string> seam, carrying the full RoleMappingResult (including PermittedSiteIds and IsSystemWideDeployment) as the opaque Scope field so no site-scope information is lost at the seam boundary.
Authorization policies
Five named policies are registered by AuthorizationPolicies.AddScadaBridgeAuthorization. Every policy uses RequireClaim(JwtTokenService.RoleClaimType, ...) — no custom requirement handlers — making the policy check a direct look-up into the JWT's role claims.
| Policy | Constant | Roles satisfied |
|---|---|---|
RequireAdmin |
AuthorizationPolicies.RequireAdmin |
Administrator |
RequireDesign |
AuthorizationPolicies.RequireDesign |
Designer |
RequireDeployment |
AuthorizationPolicies.RequireDeployment |
Deployer |
OperationalAudit |
AuthorizationPolicies.OperationalAudit |
Administrator, Viewer |
AuditExport |
AuthorizationPolicies.AuditExport |
Administrator |
Role names are declared in Roles (the single source of truth). The four active roles (Administrator, Designer, Deployer, Viewer) are the canonical subset of the shared six-role vocabulary; Operator and Engineer exist upstream but are not used. The OperationalAudit and AuditExport roles arrays are public (AuthorizationPolicies.OperationalAuditRoles, AuditExportRoles) so the ManagementService HTTP API can reuse the exact same sets when gating /api/audit/* routes through its own Basic-Auth + LDAP role check.
LDAP failure messages
LdapAuthFailureMessages.ToMessage maps the structured LdapAuthFailure enum from the shared library to user-facing strings. BadCredentials and UserNotFound both return the generic "Invalid username or password." — intentionally identical to prevent username enumeration. AmbiguousUser and ServiceAccountBindFailed (which also covers a directory that is unreachable at connect/search time) return a misconfiguration message. GroupLookupFailed (post-bind directory outage, or a successful-but-empty group set) returns a transient-outage message.
Inbound API key management
LibraryInboundApiKeyAdmin implements the Commons IInboundApiKeyAdmin seam over the shared ApiKeyAdminCommands facade. Keys use the sbk_<keyId>_<secret> token format (prefix sbk), with the key ID as a 32-hex-character GUID ("N" format, no hyphens, because hyphens are not valid in the delimiter-separated token). The library stores keys in a SQLite file (data/inbound-api-keys.sqlite by default). Scopes in the library map 1:1 to method names in ScadaBridge. Delete is implemented as revoke-then-delete because the library only permits deleting an already-revoked key.
Data Protection key sharing
AddConfigurationDatabase calls AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>(). Both central nodes therefore read and write Data Protection keys from the same MS SQL database, which means either node can protect and unprotect the same data (including the cookie payload) regardless of which node issued it — a prerequisite for load-balancer failover transparency.
Usage
Login flow (Central UI /auth/login and /auth/token):
- Call
ILdapAuthService.AuthenticateAsync(username, password)(registered by Host viaAddZbLdapAuth). - On success, call
RoleMapper.MapGroupsToRolesAsync(ldapGroups)to resolve roles and site scope. - Call
JwtTokenService.GenerateToken(displayName, username, roles, permittedSiteIds)to mint a signed JWT. - Write the JWT into the HttpOnly cookie via the ASP.NET cookie auth
SignInAsync.
On each subsequent request, middleware reads the cookie, validates the embedded JWT with JwtTokenService.ValidateToken, and checks IsIdleTimedOut. If the token is near expiry (ShouldRefresh), fresh claims are re-queried from LDAP and RefreshToken issues a replacement. Genuine user interactions call RecordActivity to advance the last-activity anchor.
Authorization gates use the named policies:
// Razor page or controller — declarative
[Authorize(Policy = AuthorizationPolicies.RequireDesign)]
// ManagementActor — imperative, reusing the same role arrays
if (!AuthorizationPolicies.OperationalAuditRoles.Contains(userRole))
return Unauthorized();
Configuration
SecurityOptions is bound from the ScadaBridge:Security section. LDAP connection settings are bound separately from ScadaBridge:Security:Ldap (the LdapSectionPath constant) into the shared LdapOptions by AddZbLdapAuth.
ScadaBridge:Security — SecurityOptions
| Key | Default | Description |
|---|---|---|
JwtSigningKey |
(required) | Symmetric HMAC-SHA256 signing key. Must be at least 32 bytes (256 bits); validated at JwtTokenService construction — startup fails if too short. |
JwtExpiryMinutes |
15 |
JWT lifetime in minutes before embedded token expires and must be refreshed. |
JwtRefreshThresholdMinutes |
5 |
Minutes before JWT expiry at which ShouldRefresh triggers a re-issue. |
IdleTimeoutMinutes |
30 |
Session idle timeout in minutes. Cookie ExpireTimeSpan is set to this value; IsIdleTimedOut enforces it from the LastActivity claim. |
RequireHttpsCookie |
true |
When true, the cookie is Secure-only (HTTPS required). Set false for HTTP-only dev deployments; a warning is logged at startup. |
CookieName |
ZB.MOM.WW.ScadaBridge.Auth |
Authentication cookie name. Override per deployment when two ScadaBridge stacks share a hostname — browsers scope cookies by host+path but not by port. |
ScadaBridge:Security:Ldap — LdapOptions (shared library)
| Key | Description |
|---|---|
Server |
LDAP/AD server hostname or IP. |
Port |
LDAP port. Use 636 for LDAPS or 389 for StartTLS. |
Transport |
Ldaps, StartTls, or None (dev only — requires AllowInsecure = true). |
AllowInsecure |
Must be true to permit Transport = None. Default false. |
SearchBase |
LDAP search base DN (e.g. dc=corp,dc=example,dc=com). |
ServiceAccountDn |
Service-account distinguished name used for the initial bind and group search. |
ServiceAccountPassword |
Service-account password. |
LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth) enforces that Server, SearchBase, ServiceAccountDn, and a secure transport are configured before the first request is served.
Dependencies & Interactions
- Commons (#16) — defines
ISecurityRepository(LDAP mapping + scope rule read/write),IInboundApiKeyAdmin(key admin seam),IAuditActorAccessor(audit actor resolution),LdapGroupMapping, andSiteScopeRuleentities, plusManagementEnvelope(carries username/roles into every Management command). - Configuration Database (#17) — provides the scoped
ISecurityRepositoryimplementation (SecurityRepository) backed byLdapGroupMappingsandSiteScopeRulestables in MS SQL, and hosts the Data Protection key ring viaPersistKeysToDbContext<ScadaBridgeDbContext>(). - Central UI (#9) — every Blazor Server page and Razor component passes through cookie authentication and named policy authorization. The login page drives the LDAP bind → role map → token mint flow. The Admin → LDAP Mappings page is gated by
RequireAdminand callsISecurityRepositorydirectly. - Management Service (#18) — the
ManagementActorenforces role and site-scope rules on every incoming command using identity carried in theManagementEnvelope. The CLI authenticates users via the same LDAP bind and passes identity in every request. - Inbound API (#14) — inbound API requests authenticate via
Authorization: Bearer sbk_<keyId>_<secret>(library verifier,sbk_*token format) rather than the cookie/JWT path.HttpAuditActorAccessorresolves the authenticated username for auditActorstamping on the interactive path; the inbound API path keeps its own actor/fallback. - Audit Log (#23) —
IAuditActorAccessoris a seam this component implements; the Inbound API audit path callsCurrentActorto record the authenticated user as the event actor. - Transport (#24) — export gates on
RequireDesign; import gates onRequireAdmin, enforced at both the Razor page layer and inside the Transport service entrypoints. - Design spec: Component-Security.md.
Troubleshooting
Login fails: "Authentication service is misconfigured"
This message maps from LdapAuthFailure.ServiceAccountBindFailed or LdapAuthFailure.AmbiguousUser. The service-account DN or password in ScadaBridge:Security:Ldap is wrong, the LDAP server is unreachable at connect time, or the search for the username returned more than one entry. Check ServiceAccountDn, ServiceAccountPassword, and Server in configuration. LdapOptionsValidator enforces these keys at startup, so a complete absence fails fast — this error at login time indicates a runtime connectivity or data problem.
Login fails: "The directory is temporarily unavailable"
Maps from LdapAuthFailure.GroupLookupFailed. The user-credential bind succeeded but the subsequent group-membership query failed. The directory is partially reachable (user bind works) but the group search is failing. Existing sessions with valid JWTs continue to operate unaffected.
Session expires unexpectedly
Check IdleTimeoutMinutes and JwtExpiryMinutes in SecurityOptions. A background refresh that fires while the user is idle preserves the LastActivity anchor (RefreshToken does not advance it); IsIdleTimedOut enforces the window from the last genuine user interaction. If the idle timeout fires before the expected window, confirm that RecordActivity is being called on genuine user requests.
Two ScadaBridge environments on the same host clobber each other's session
Set a distinct CookieName in ScadaBridge:Security for each deployment. Browsers scope cookies by host+path, not by port, so two stacks on localhost:9000 and localhost:9100 share cookie namespace under the default name.