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.
This commit is contained in:
Joseph Doherty
2026-05-29 07:43:11 -04:00
parent bc4fce5fbe
commit ee8add4416
2 changed files with 663 additions and 0 deletions
@@ -0,0 +1,652 @@
# 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):
```csharp
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:
```csharp
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:
```bash
cd /Users/dohertj2/Desktop/OtOpcUa
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
```
Expected: 0 errors, 0 warnings.
### Step 3: Commit
```bash
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
```bash
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:
```csharp
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:
```csharp
// 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
```bash
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
```bash
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:
```csharp
app.UseEndpoints(e => e.MapOtOpcUaAuth());
```
to:
```csharp
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:
```csharp
/// <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):
```csharp
/// <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
```bash
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
```bash
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
```bash
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):
```xml
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
```
Delete it. **Keep** these:
```xml
<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
```bash
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
```bash
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
```bash
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:
```bash
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:
```bash
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
```bash
# 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:
```bash
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)
```bash
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.
@@ -0,0 +1,11 @@
{
"planPath": "docs/plans/2026-05-29-auth-alignment-plan.md",
"tasks": [
{"id": 1, "subject": "Task 1: Extend OtOpcUaCookieOptions", "status": "pending"},
{"id": 2, "subject": "Task 2: Rewrite auth wiring + update cookie-name assertion", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Add browser-vs-AJAX challenge tests", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Remove JwtBearer package reference", "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 5: Manual smoke + final commit", "status": "pending", "blockedBy": [3, 4]}
],
"lastUpdated": "2026-05-29T00:00:00Z"
}