diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
index acbffcc0..a66481f8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
@@ -329,63 +329,16 @@ public static class AuditEndpoints
private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
///
- /// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same
- /// flow uses. Returns a populated
- /// on success, or an
- /// carrying the 401 response on any failure.
+ /// Authenticates the audit-REST request via the shared
+ /// — the dev/test
+ /// DisableLogin bypass first, otherwise HTTP Basic → LDAP → roles, the same flow
+ /// uses. Returns a populated
+ /// on success, or an carrying the 401 response on any failure.
///
private static async Task AuthenticateAsync(HttpContext context)
{
- var authHeader = context.Request.Headers.Authorization.ToString();
- if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
- {
- return new AuthOutcome(null, Results.Json(
- new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
- }
-
- string username, password;
- try
- {
- var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
- var colon = decoded.IndexOf(':');
- if (colon < 0) throw new FormatException();
- username = decoded[..colon];
- password = decoded[(colon + 1)..];
- }
- catch
- {
- return new AuthOutcome(null, Results.Json(
- new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
- }
-
- if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
- {
- return new AuthOutcome(null, Results.Json(
- new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
- }
-
- var ldapAuth = context.RequestServices.GetRequiredService();
- var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
- if (!authResult.Succeeded)
- {
- return new AuthOutcome(null, Results.Json(
- new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
- }
-
- var roleMapper = context.RequestServices.GetRequiredService();
- var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
-
- var permittedSiteIds = mappingResult.IsSystemWideDeployment
- ? Array.Empty()
- : mappingResult.PermittedSiteIds.ToArray();
-
- var user = new AuthenticatedUser(
- authResult.Username,
- authResult.DisplayName,
- mappingResult.Roles.ToArray(),
- permittedSiteIds);
-
- return new AuthOutcome(user, null);
+ var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
+ return new AuthOutcome(outcome.User, outcome.Failure);
}
private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) =>
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs
index fb2ac1de..2581e9f8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs
@@ -75,6 +75,21 @@ public class DebugStreamHub : Hub
return;
}
+ // Dev/test: when login is disabled, auto-authenticate the debug stream as the configured
+ // dev user (ALL roles incl. Deployer, system-wide) — mirroring the cookie/management
+ // bypass so the CLI debug stream is usable in a login-disabled (e.g. no-LDAP) deployment.
+ // See ManagementAuthenticator.
+ var bypassUser = ManagementAuthenticator.TryDisableLoginUser(httpContext);
+ if (bypassUser is not null)
+ {
+ Context.Items[RolesKey] = bypassUser.Roles;
+ Context.Items[PermittedSiteIdsKey] = bypassUser.PermittedSiteIds;
+ _logger.LogInformation(
+ "DebugStreamHub connection established for {Username} (login disabled)", bypassUser.Username);
+ await base.OnConnectedAsync();
+ return;
+ }
+
// Extract Basic Auth credentials
var authHeader = httpContext.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs
new file mode 100644
index 00000000..aa277300
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs
@@ -0,0 +1,132 @@
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.Auth.Abstractions.Ldap;
+using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
+using ZB.MOM.WW.ScadaBridge.Security;
+using ZB.MOM.WW.ScadaBridge.Security.Auth;
+
+namespace ZB.MOM.WW.ScadaBridge.ManagementService;
+
+///
+/// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces
+/// (, , and the debug-stream hub).
+/// Centralising the flow here means the dev/test DisableLogin bypass is applied
+/// identically on every CLI-facing surface.
+///
+///
+///
+/// ScadaBridge:Security:Auth:DisableLogin swaps the cookie authentication
+/// scheme for AutoLoginAuthenticationHandler, which lets the interactive browser UI
+/// in without credentials. The CLI does not use cookies — it authenticates the
+/// POST /management (and audit REST) surfaces with HTTP Basic, which run their own
+/// manual Basic → LDAP check. That check is independent of the cookie scheme, so without the
+/// bypass below the CLI is locked out of a login-disabled deployment whenever LDAP is not in
+/// use (the bind fails and every call returns 401 AUTH_FAILED).
+///
+///
+/// The bypass mirrors AutoLoginAuthenticationHandler exactly: the configured dev user
+/// (default multi-role) with ALL roles, system-wide (empty
+/// ). The startup AUTH DISABLED warning
+/// (emitted by AddSecurity) already announces this posture. Dev/test ONLY.
+///
+///
+public static class ManagementAuthenticator
+{
+ /// Outcome of : exactly one of the two fields is set.
+ /// The authenticated principal on success; otherwise .
+ /// The 401 to return on failure; otherwise .
+ public readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
+
+ ///
+ /// When ScadaBridge:Security:Auth:DisableLogin is true, returns the synthesized dev
+ /// principal (configured user, ALL roles, system-wide) that mirrors the cookie-scheme
+ /// AutoLoginAuthenticationHandler — so the Basic-Auth CLI surfaces are reachable in a
+ /// login-disabled (e.g. no-LDAP) deployment. Returns when the flag is
+ /// off, in which case the caller must fall back to real Basic → LDAP authentication.
+ /// Dev/test ONLY.
+ ///
+ /// The current HTTP request context (used to resolve the options).
+ /// The dev principal when login is disabled; otherwise .
+ public static AuthenticatedUser? TryDisableLoginUser(HttpContext context)
+ {
+ var opts = context.RequestServices.GetService>()?.Value;
+ if (opts is null || !opts.DisableLogin)
+ return null;
+
+ var user = string.IsNullOrWhiteSpace(opts.User) ? "multi-role" : opts.User;
+ return new AuthenticatedUser(user, user, Roles.All, Array.Empty());
+ }
+
+ ///
+ /// Authenticates a management/CLI request. Honours the dev/test DisableLogin bypass
+ /// first (); otherwise decodes HTTP Basic Auth, binds
+ /// against LDAP, and resolves roles. Returns a populated on
+ /// success, or an carrying the 401 response on any failure.
+ ///
+ /// The HTTP request to authenticate.
+ /// An carrying either the user or the 401 failure result.
+ public static async Task AuthenticateAsync(HttpContext context)
+ {
+ // Dev/test bypass: when login is disabled, the CLI surface auto-authenticates as the
+ // configured dev user — no credential check — mirroring the interactive cookie handler.
+ var bypass = TryDisableLoginUser(context);
+ if (bypass is not null)
+ return new AuthOutcome(bypass, null);
+
+ // 1. Decode Basic Auth
+ var authHeader = context.Request.Headers.Authorization.ToString();
+ if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
+ {
+ return new AuthOutcome(null, Results.Json(
+ new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
+ }
+
+ string username, password;
+ try
+ {
+ var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
+ var colon = decoded.IndexOf(':');
+ if (colon < 0) throw new FormatException();
+ username = decoded[..colon];
+ password = decoded[(colon + 1)..];
+ }
+ catch
+ {
+ return new AuthOutcome(null, Results.Json(
+ new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
+ }
+
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ return new AuthOutcome(null, Results.Json(
+ new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
+ }
+
+ // 2. LDAP authentication
+ var ldapAuth = context.RequestServices.GetRequiredService();
+ var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
+ if (!authResult.Succeeded)
+ {
+ return new AuthOutcome(null, Results.Json(
+ new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
+ }
+
+ // 3. Role resolution
+ var roleMapper = context.RequestServices.GetRequiredService();
+ var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
+
+ var permittedSiteIds = mappingResult.IsSystemWideDeployment
+ ? Array.Empty()
+ : mappingResult.PermittedSiteIds.ToArray();
+
+ var resolvedUser = new AuthenticatedUser(
+ authResult.Username,
+ authResult.DisplayName,
+ mappingResult.Roles.ToArray(),
+ permittedSiteIds);
+
+ return new AuthOutcome(resolvedUser, null);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
index 7d9d9095..4883f033 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
@@ -61,55 +61,17 @@ public static class ManagementEndpoints
maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes;
}
- // 1. Decode Basic Auth
- var authHeader = context.Request.Headers.Authorization.ToString();
- if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
+ // 1-3. Authenticate: dev/test DisableLogin bypass → else HTTP Basic → LDAP → roles.
+ // Centralised in ManagementAuthenticator so every CLI surface honours DisableLogin
+ // identically — the cookie auto-login (AutoLoginAuthenticationHandler) only covers the
+ // interactive UI, not this Basic-Auth surface.
+ var auth = await ManagementAuthenticator.AuthenticateAsync(context);
+ if (auth.Failure is not null)
{
- return Results.Json(new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401);
+ return auth.Failure;
}
- string username, password;
- try
- {
- var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
- var colon = decoded.IndexOf(':');
- if (colon < 0) throw new FormatException();
- username = decoded[..colon];
- password = decoded[(colon + 1)..];
- }
- catch
- {
- return Results.Json(new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401);
- }
-
- if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
- {
- return Results.Json(new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401);
- }
-
- // 2. LDAP authentication
- var ldapAuth = context.RequestServices.GetRequiredService();
- var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
- if (!authResult.Succeeded)
- {
- return Results.Json(
- new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" },
- statusCode: 401);
- }
-
- // 3. Role resolution
- var roleMapper = context.RequestServices.GetRequiredService();
- var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
-
- var permittedSiteIds = mappingResult.IsSystemWideDeployment
- ? Array.Empty()
- : mappingResult.PermittedSiteIds.ToArray();
-
- var authenticatedUser = new AuthenticatedUser(
- authResult.Username,
- authResult.DisplayName,
- mappingResult.Roles.ToArray(),
- permittedSiteIds);
+ var authenticatedUser = auth.User!;
// 4. Parse command from request body
string body;
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs
new file mode 100644
index 00000000..a19212e9
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.ManagementService;
+using ZB.MOM.WW.ScadaBridge.Security;
+using ZB.MOM.WW.ScadaBridge.Security.Auth;
+
+namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
+
+///
+/// Tests for the dev/test DisableLogin bypass on the management/CLI auth surface
+/// (). The bypass lets the HTTP-Basic CLI surfaces
+/// authenticate without LDAP when login is disabled — mirroring the interactive cookie
+/// handler — so a login-disabled deployment is not locked out of the CLI.
+///
+public class ManagementAuthenticatorTests
+{
+ private static HttpContext ContextWith(AuthDisableLoginOptions options)
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton>(Options.Create(options));
+ return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
+ }
+
+ [Fact]
+ public void TryDisableLoginUser_WhenDisabled_ReturnsNull()
+ {
+ var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false });
+
+ Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
+ }
+
+ [Fact]
+ public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull()
+ {
+ var context = new DefaultHttpContext
+ {
+ RequestServices = new ServiceCollection().BuildServiceProvider(),
+ };
+
+ Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
+ }
+
+ [Fact]
+ public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide()
+ {
+ var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = "ci-bot" });
+
+ var user = ManagementAuthenticator.TryDisableLoginUser(context);
+
+ Assert.NotNull(user);
+ Assert.Equal("ci-bot", user!.Username);
+ Assert.Equal("ci-bot", user.DisplayName);
+ Assert.Equal(Roles.All, user.Roles);
+ Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler
+ }
+
+ [Fact]
+ public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole()
+ {
+ var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = " " });
+
+ var user = ManagementAuthenticator.TryDisableLoginUser(context);
+
+ Assert.NotNull(user);
+ Assert.Equal("multi-role", user!.Username);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser()
+ {
+ // No Authorization header and no ILdapAuthService registered: the bypass must short-circuit
+ // before either is consulted.
+ var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true });
+
+ var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
+
+ Assert.Null(outcome.Failure);
+ Assert.NotNull(outcome.User);
+ Assert.Equal("multi-role", outcome.User!.Username);
+ Assert.Equal(Roles.All, outcome.User.Roles);
+ }
+
+ [Fact]
+ public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized()
+ {
+ // Login NOT disabled and no Basic header: the real auth path rejects with a 401 result
+ // before any LDAP lookup.
+ var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false });
+
+ var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
+
+ Assert.Null(outcome.User);
+ Assert.NotNull(outcome.Failure);
+ }
+}