Files
lmxopcua/docs/plans/2026-05-29-auth-alignment-plan.md
T
Joseph Doherty ee8add4416 docs: implementation plan for auth/login alignment with ScadaBridge
5 tasks following Section 6 of the approved design (bc4fce5). Tasks 3 and 4
parallelizable. Each task carries Classification + Estimated implement time
+ Parallelizable-with metadata for subagent dispatch.
2026-05-29 07:43:11 -04:00

28 KiB

Auth/login alignment with ScadaBridge — implementation plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans or superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.

Goal: Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused OtOpcUaCookieOptions.

Architecture: Cookie-only auth. JwtTokenService keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through OtOpcUaCookieOptions via a Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory> PostConfigure step. Endpoint surface (/auth/login, /auth/logout, /auth/ping, /auth/token) unchanged.

Tech stack: .NET 10 / ASP.NET Core / Microsoft.AspNetCore.Authentication.Cookies / xUnit v3 + Shouldly / Microsoft.AspNetCore.TestHost.TestServer.

Design doc: docs/plans/2026-05-29-auth-alignment-design.md (commit bc4fce5). Each task below cites the design section it implements.


Sequencing

Task 1 (Options class)
   └─► Task 2 (Wiring rewrite + test assertion update)
          ├─► Task 3 (3 new challenge tests)
          └─► Task 4 (csproj cleanup)
                 └─► Task 5 (manual smoke + final commit)

Tasks 3 and 4 are parallelizable (disjoint files).


Task 1 — Extend OtOpcUaCookieOptions

Classification: trivial Estimated implement time: ~2 min Parallelizable with: none (Task 2 depends on this)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs

Implements design: Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).

Step 1: Replace file contents

Current file (12 lines):

namespace ZB.MOM.WW.OtOpcUa.Security;

public sealed class OtOpcUaCookieOptions
{
    public const string SectionName = "Security:Cookie";

    /// <summary>Gets or sets the cookie name.</summary>
    public string Name { get; set; } = "OtOpcUa.Auth";

    /// <summary>Idle sliding window, in minutes (default 30).</summary>
    public int ExpiryMinutes { get; set; } = 30;
}

Replace with:

namespace ZB.MOM.WW.OtOpcUa.Security;

/// <summary>
///     Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
///     <c>Configure&lt;IOptions&lt;OtOpcUaCookieOptions&gt;, ILoggerFactory&gt;</c> step inside
///     <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
/// </summary>
public sealed class OtOpcUaCookieOptions
{
    /// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
    public const string SectionName = "Security:Cookie";

    /// <summary>
    ///     Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
    ///     <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
    ///     deploy.
    /// </summary>
    public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";

    /// <summary>Idle sliding-window length in minutes (default 30).</summary>
    public int ExpiryMinutes { get; set; } = 30;

    /// <summary>
    ///     Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
    ///     <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
    ///     plain HTTP — emits a startup Warning when disabled so the misconfiguration is
    ///     audible.
    /// </summary>
    public bool RequireHttpsCookie { get; set; } = true;
}

Step 2: Build

Run:

cd /Users/dohertj2/Desktop/OtOpcUa
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/

Expected: 0 errors, 0 warnings.

Step 3: Commit

git -C /Users/dohertj2/Desktop/OtOpcUa add src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default"

Output report

  • Lines before / after
  • Build clean
  • Commit SHA

Self-review checklist

  • Name default is "ZB.MOM.WW.OtOpcUa.Auth" (NOT "OtOpcUa.Auth")
  • RequireHttpsCookie field added with default true and XML doc explaining the dev-only opt-out
  • ExpiryMinutes default unchanged at 30
  • SectionName constant unchanged
  • Build clean

Task 2 — Rewrite auth wiring in ServiceCollectionExtensions.cs

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (Tasks 3 and 4 depend on this)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
  • Modify: tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs:93

Implements design: Section 1 + Section 2 file table rows 2 + 3.

Step 1: Read current file

cat /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs

Current shape (relevant excerpt):

  • using Microsoft.AspNetCore.Authentication.JwtBearer; at top
  • internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) : IPostConfigureOptions<JwtBearerOptions> class (lines ~15-35)
  • .AddCookie(o => { ... }) with OnRedirectToLogin / OnRedirectToAccessDenied overrides
  • .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { }) chained after AddCookie
  • services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>() after the AddAuthentication block
  • FallbackPolicy builder takes both Cookie + JwtBearer schemes

Step 2: Replace the file with the new shape

The full target file:

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;

namespace ZB.MOM.WW.OtOpcUa.Security;

/// <summary>
///     DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
///     cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
///     structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
/// </summary>
public static class ServiceCollectionExtensions
{
    /// <summary>Wires cookie authentication, DataProtection key persistence to ConfigDb,
    /// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
    /// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
    /// challenge heuristic).</summary>
    /// <param name="services">The service collection.</param>
    /// <param name="configuration">The application configuration root.</param>
    public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
        services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
        services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));

        services.AddSingleton<JwtTokenService>();
        // Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
        // must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
        services.AddSingleton<ILdapAuthService, LdapAuthService>();

        services.AddDataProtection()
            .PersistKeysToDbContext<OtOpcUaConfigDbContext>()
            .SetApplicationName("OtOpcUa");

        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(o =>
            {
                // Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
                // are bound from OtOpcUaCookieOptions in the PostConfigure block below.
                o.LoginPath = "/login";
                o.LogoutPath = "/auth/logout";
                o.Cookie.HttpOnly = true;
                o.Cookie.SameSite = SameSiteMode.Strict;
                // No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
                // built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
            });

        // Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
        // pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
        services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
            .Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
            {
                var v = ourOpts.Value;
                cookieOpts.Cookie.Name = v.Name;
                cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
                cookieOpts.SlidingExpiration = true;
                cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
                    ? CookieSecurePolicy.Always
                    : CookieSecurePolicy.SameAsRequest;

                if (!v.RequireHttpsCookie)
                {
                    lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
                        "Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
                        "SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
                        "Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
                }
            });

        services.AddAuthorization(o =>
        {
            o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
                    CookieAuthenticationDefaults.AuthenticationScheme)
                .RequireAuthenticatedUser()
                .Build();

            // DriverOperator: may issue Reconnect/Restart commands against live driver instances
            // from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
            // appsettings (e.g. "ot-driver-operator": "DriverOperator").
            o.AddPolicy("DriverOperator", policy =>
                policy.RequireRole("DriverOperator", "FleetAdmin"));
        });

        return services;
    }
}

What's gone (vs. the original):

  • using Microsoft.AspNetCore.Authentication.JwtBearer;
  • ConfigureJwtBearerFromTokenService internal class entirely
  • .AddJwtBearer(...) chain after .AddCookie(...)
  • services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
  • OnRedirectToLogin / OnRedirectToAccessDenied event overrides
  • Hardcoded o.Cookie.Name = "OtOpcUa.Auth", o.SlidingExpiration = true, o.ExpireTimeSpan = TimeSpan.FromMinutes(30), o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest
  • JwtBearerDefaults.AuthenticationScheme from the FallbackPolicy builder

What's added:

  • using Microsoft.Extensions.Logging;
  • o.LoginPath = "/login", o.LogoutPath = "/auth/logout" inside .AddCookie(...)
  • The services.AddOptions<CookieAuthenticationOptions>(...).Configure<...>(...) PostConfigure block

Step 3: Update the one existing test assertion

In tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs around line 93:

// before
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
// after
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));

Step 4: Build + run security tests

cd /Users/dohertj2/Desktop/OtOpcUa
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/

Expected: build clean; all Security.Tests pass (the existing 5 AuthEndpointsIntegrationTests + JwtTokenServiceTests + LdapHelperTests + RoleMapperTests).

Step 5: Commit

git -C /Users/dohertj2/Desktop/OtOpcUa add \
  src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs \
  tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "$(cat <<'EOF'
refactor(security): drop JwtBearer parallel scheme, externalize cookie config

Single Cookie auth scheme; framework default challenge restores 302 → /login
for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to
CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the
options class was bound but ignored). Cookie name moves to
ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
EOF
)"

Output report

  • Net LOC change (additions / deletions)
  • Build clean
  • Test count run / passed
  • Commit SHA
  • Anything unexpected

Self-review checklist

  • using Microsoft.AspNetCore.Authentication.JwtBearer; removed
  • ConfigureJwtBearerFromTokenService class deleted
  • .AddJwtBearer(...) call deleted
  • IPostConfigureOptions<JwtBearerOptions> singleton registration deleted
  • OnRedirectToLogin and OnRedirectToAccessDenied overrides deleted
  • LoginPath = "/login" and LogoutPath = "/auth/logout" added inside .AddCookie(...)
  • PostConfigure block added consuming OtOpcUaCookieOptions
  • Warning log fires when RequireHttpsCookie == false
  • FallbackPolicy now takes only CookieAuthenticationDefaults.AuthenticationScheme
  • DriverOperator policy unchanged
  • Test assertion updated to ZB.MOM.WW.OtOpcUa.Auth=
  • dotnet test tests/Server/.../Security.Tests/ all green

Task 3 — Add browser-vs-AJAX challenge tests

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 4

Files:

  • Modify: tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs (append 3 new test methods + 1 helper)

Implements design: Section 5 "Tests added" + Section 4 "Auth challenge for unknown content type".

Context for the implementer

AuthEndpointsIntegrationTests is IAsyncLifetime-backed and stands up a TestServer with MapOtOpcUaAuth() mounted (line 66). The web.UseEndpoints(e => e.MapOtOpcUaAuth()) wires ONLY the four /auth/* endpoints — there is NO root MapGet("/", ...) registered. So an anonymous GET to / hits the routing pipeline, falls through to a 404 BEFORE auth middleware even challenges.

The test harness needs a protected root endpoint. Add one in InitializeAsync inside the web.UseEndpoints(...) callback. Then the 3 new tests will exercise the cookie scheme's challenge for that protected route.

Step 1: Modify the test host setup

In AuthEndpointsIntegrationTests.cs, change web.UseEndpoints(...) (around line 66) from:

app.UseEndpoints(e => e.MapOtOpcUaAuth());

to:

app.UseEndpoints(e =>
{
    e.MapOtOpcUaAuth();
    // Protected root used by AuthChallengeTests below — exercises the cookie
    // scheme's challenge heuristic without depending on the full Razor host.
    e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
});

Step 2: Add the three new test methods

Append at the bottom of the class (before the closing brace), keeping the file's existing summary style and using TestContext.Current.CancellationToken via the existing Ct property:

/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
[Fact]
public async Task Root_anonymous_browser_GET_redirects_to_login()
{
    var client = NewClientNoRedirect();
    var req = new HttpRequestMessage(HttpMethod.Get, "/");
    req.Headers.Accept.ParseAdd("text/html");
    var resp = await client.SendAsync(req, Ct);

    resp.StatusCode.ShouldBe(HttpStatusCode.Found);
    resp.Headers.Location.ShouldNotBeNull();
    resp.Headers.Location!.OriginalString.ShouldContain("/login");
    resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
}

/// <summary>Anonymous AJAX GET of a protected route returns 401 with no Location.</summary>
[Fact]
public async Task Root_anonymous_ajax_GET_returns_401()
{
    var client = NewClientNoRedirect();
    var req = new HttpRequestMessage(HttpMethod.Get, "/");
    req.Headers.Add("X-Requested-With", "XMLHttpRequest");
    var resp = await client.SendAsync(req, Ct);

    resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
    resp.Headers.Location.ShouldBeNull();
}

/// <summary>Anonymous JSON GET of a protected route returns 401.</summary>
[Fact]
public async Task Root_anonymous_json_GET_returns_401()
{
    var client = NewClientNoRedirect();
    var req = new HttpRequestMessage(HttpMethod.Get, "/");
    req.Headers.Accept.ParseAdd("application/json");
    var resp = await client.SendAsync(req, Ct);

    resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}

Step 3: Add the no-redirect client helper

Right next to the existing NewClient() method (line 82):

/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
/// Used by challenge tests so we can assert on the 302 / Location directly.</summary>
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
{
    BaseAddress = _server.BaseAddress,
};

Step 4: Run the tests

cd /Users/dohertj2/Desktop/OtOpcUa
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/

Expected: existing 5 tests still pass + 3 new tests pass = 8+ total green.

If Root_anonymous_browser_GET_redirects_to_login returns 200 instead of 302: HttpClient is still auto-following redirects. Two fixes to try in order:

  1. Confirm NewClientNoRedirect uses _server.CreateHandler() (not CreateClient()).
  2. If still wrong, swap to: var handler = new HttpClientHandler { AllowAutoRedirect = false }; — but TestServer doesn't expose HttpClientHandler directly. The CreateHandler() path SHOULD return a non-redirecting handler; if it doesn't, the implementation may need a DelegatingHandler wrapper.

If Root_anonymous_browser_GET_redirects_to_login returns 401 instead of 302: the cookie scheme isn't classifying Accept: text/html as a browser. Inspect Task 2's changes — OnRedirectToLogin may not have been fully removed, OR LoginPath was not set, OR an Accept parsing issue. Look at the response body — if it's empty + 401, the JwtBearer scheme or the override is still in play.

Step 5: Commit

git -C /Users/dohertj2/Desktop/OtOpcUa add tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "test(security): add browser-vs-AJAX challenge tests for root path"

Output report

  • 3 new tests + 1 helper + modified InitializeAsync
  • Build clean
  • Test count: existing N + 3 new = N+3 green
  • Commit SHA
  • Anything unexpected (e.g. redirect-following behavior of _server.CreateHandler())

Self-review checklist

  • MapGet("/", ...).RequireAuthorization() added inside web.UseEndpoints(...)
  • NewClientNoRedirect() helper added
  • 3 new [Fact] methods added with TestContext.Current.CancellationToken via the Ct property
  • Each test asserts on the exact status + Location header (or absence)
  • All tests green
  • Existing 5 tests still pass

Task 4 — Remove Microsoft.AspNetCore.Authentication.JwtBearer package reference

Classification: trivial Estimated implement time: ~2 min Parallelizable with: Task 3

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj (delete one line)
  • Verify: Directory.Packages.props — leave the <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ... /> entry in place (other projects may consume it).

Implements design: Section 2 "Package references" + Section 6 phase 4.

Step 1: Confirm no remaining consumer in the Security project

grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer\|JwtBearer" \
  /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ \
  --include="*.cs"

Expected: zero matches. (Task 2 removed all uses.) If there are matches, STOP and report — Task 2 was incomplete.

Step 2: Remove the PackageReference

In src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj, find this line (currently around line 13):

<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>

Delete it. Keep these:

<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>

(JwtTokenService consumes those for TokenValidationParameters + JWT creation respectively — they're not from the JwtBearer authentication package.)

Step 3: Check whether ANY other project still references the package

grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer" \
  /Users/dohertj2/Desktop/OtOpcUa/src/ /Users/dohertj2/Desktop/OtOpcUa/tests/ \
  --include="*.csproj"

If zero results: also remove the <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ...> line from Directory.Packages.props (search for it). If one or more other projects still reference it, leave Directory.Packages.props alone.

Step 4: Restore + build

cd /Users/dohertj2/Desktop/OtOpcUa
dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.Security/
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
dotnet build ZB.MOM.WW.OtOpcUa.slnx

Expected: 0 NEW errors. The known pre-existing 12 errors (OpcUaServer.Tests + Runtime.Tests + AbLegacy.Cli + S7.Cli) remain unchanged.

Step 5: Commit

git -C /Users/dohertj2/Desktop/OtOpcUa add \
  src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj \
  Directory.Packages.props  # only if you also removed it from Directory.Packages.props
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused)"

If only the csproj changed: omit Directory.Packages.props from the add.

Output report

  • Was Directory.Packages.props also touched? Justify based on whether other projects still reference the package.
  • Build clean (0 new errors)
  • Commit SHA

Self-review checklist

  • Confirmed zero Microsoft.AspNetCore.Authentication.JwtBearer or JwtBearer matches in src/Server/ZB.MOM.WW.OtOpcUa.Security/**/*.cs before deletion
  • PackageReference removed from Security.csproj
  • Microsoft.IdentityModel.Tokens and System.IdentityModel.Tokens.Jwt kept
  • Directory.Packages.props touched ONLY if no other project consumes the package
  • Full solution build adds zero new errors

Task 5 — Manual smoke + final commit

Classification: trivial Estimated implement time: ~3 min Parallelizable with: none

Files: none (verification + optional cleanup commit)

Implements design: Section 5 "Manual smoke" + Section 6 phase 5.

Step 1: Restart the docker-dev cluster

The admin nodes need to pick up the new Microsoft.AspNetCore.TestHost-side code path AND the new cookie name. Since the in-cluster admin processes run a prior build, force a rebuild + recreate:

cd /Users/dohertj2/Desktop/OtOpcUa
docker compose -f docker-dev/docker-compose.yml up -d --build admin-a admin-b

Wait ~15 s for warm-up. Then:

docker compose -f docker-dev/docker-compose.yml ps admin-a admin-b

Both should show Up and (healthy) (or Up if no healthcheck).

Step 2: curl smoke

# Anonymous browser-shaped GET → 302 to /login with ReturnUrl
curl -i -H "Accept: text/html" http://localhost:9200/ 2>&1 | head -12
# Expected: HTTP/1.1 302 Found, Location: /login?ReturnUrl=%2F

# Anonymous AJAX GET → 401
curl -i -H "X-Requested-With: XMLHttpRequest" http://localhost:9200/ 2>&1 | head -8
# Expected: HTTP/1.1 401 Unauthorized

# Anonymous JSON GET → 401
curl -i -H "Accept: application/json" http://localhost:9200/ 2>&1 | head -8
# Expected: HTTP/1.1 401 Unauthorized

# Login form → 302 with Set-Cookie ZB.MOM.WW.OtOpcUa.Auth
curl -i -X POST -d "username=alice&password=alice" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  http://localhost:9200/auth/login 2>&1 | head -15
# Expected: HTTP/1.1 302 Found, Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... (the test stub user may differ — check docker-compose's GLAuth seed for a valid LDAP creds pair)

Step 3: Chrome smoke (via the macbook browser instance from earlier in the session)

  1. Open http://localhost:9200/ — should redirect to /login?ReturnUrl=%2F (not Chrome's error page)
  2. Sign in via the form
  3. DevTools → Application → Cookies → confirm cookie name is ZB.MOM.WW.OtOpcUa.Auth
  4. Navigate to http://localhost:9200/ again — should render the AdminUI dashboard
  5. Click logout → confirm redirect back to /login

Step 4: Optional CLAUDE.md update

If CLAUDE.md mentions the old OtOpcUa.Auth cookie name anywhere, update to the new ZB.MOM.WW.OtOpcUa.Auth. Run:

grep -n "OtOpcUa\.Auth" /Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md

If matches: update them, otherwise skip.

Step 5: Final commit (only if Step 4 changed CLAUDE.md)

git -C /Users/dohertj2/Desktop/OtOpcUa add CLAUDE.md
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "docs: update cookie name reference in CLAUDE.md"

Output report

  • All 4 curl smoke checks passed?
  • Chrome smoke passed?
  • CLAUDE.md changed?
  • Final SHA on master (if any docs commit)
  • Commit count since this plan started (vs bc4fce5)

Self-review checklist

  • docker compose up -d --build admin-a admin-b succeeded
  • All 4 curl smoke checks return expected status codes
  • Chrome smoke shows redirect to /login, then dashboard after auth
  • Cookie name in DevTools matches ZB.MOM.WW.OtOpcUa.Auth
  • No new commits left uncommitted in the working tree

Verification gates (apply at end of every task)

  • dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/ — 0 errors
  • dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ — all green (existing + new)
  • dotnet build ZB.MOM.WW.OtOpcUa.slnx — no NEW errors beyond the 12 pre-existing
  • No untracked files staged accidentally (especially sql_login.txt, pki/, doc-fix artifacts)

Risk hot-spots for reviewers

  1. TestServer's no-redirect HttpClient. The plan assumes new HttpClient(_server.CreateHandler()) { BaseAddress = _server.BaseAddress } does NOT auto-follow redirects. If it does, the Root_anonymous_browser_GET_redirects_to_login test fails with 200 instead of 302. Fix path documented in Task 3 Step 4.
  2. Framework default of Accept: */* → 302. Curl's default Accept header is */*, which the framework classifies as browser → 302. Documented behavior, mirrors ScadaBridge; reviewers should not flag the smoke step that uses Accept: text/html as redundant — it's the explicit "browser" assertion.
  3. Cookie rename invalidates sessions. The deploy effectively logs every currently-signed-in user out. Document in commit body; the cluster was just restarted on the new API key anyway, so the timing is opportune.
  4. Directory.Packages.props change is conditional. Don't touch it if other projects still consume the JwtBearer package. Task 4 has explicit grep guard.
  5. /Account/AccessDenied 404. Authenticated users hitting a DriverOperator-only route now get a generic 404 page instead of a clean access-denied message. Documented design choice; follow-up to add a Razor page if UX feedback demands it.