From ee8add4416f9050d89a66c00cc8ca12c1012e76d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 07:43:11 -0400 Subject: [PATCH] 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. --- docs/plans/2026-05-29-auth-alignment-plan.md | 652 ++++++++++++++++++ ...26-05-29-auth-alignment-plan.md.tasks.json | 11 + 2 files changed, 663 insertions(+) create mode 100644 docs/plans/2026-05-29-auth-alignment-plan.md create mode 100644 docs/plans/2026-05-29-auth-alignment-plan.md.tasks.json diff --git a/docs/plans/2026-05-29-auth-alignment-plan.md b/docs/plans/2026-05-29-auth-alignment-plan.md new file mode 100644 index 00000000..af0da935 --- /dev/null +++ b/docs/plans/2026-05-29-auth-alignment-plan.md @@ -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, 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"; + + /// Gets or sets the cookie name. + public string Name { get; set; } = "OtOpcUa.Auth"; + + /// Idle sliding window, in minutes (default 30). + public int ExpiryMinutes { get; set; } = 30; +} +``` + +Replace with: +```csharp +namespace ZB.MOM.WW.OtOpcUa.Security; + +/// +/// Auth-cookie configuration bound from Security:Cookie. Consumed by a +/// Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory> step inside +/// AddOtOpcUaAuth that copies the values onto CookieAuthenticationOptions. +/// +public sealed class OtOpcUaCookieOptions +{ + /// Configuration section name (Security:Cookie). + public const string SectionName = "Security:Cookie"; + + /// + /// Auth cookie name. Default uses the ZB.MOM.WW convention; mirrors ScadaBridge's + /// ZB.MOM.WW.ScadaBridge.Auth. Changing this invalidates existing sessions on next + /// deploy. + /// + public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth"; + + /// Idle sliding-window length in minutes (default 30). + public int ExpiryMinutes { get; set; } = 30; + + /// + /// Require HTTPS for the auth cookie. Default true: cookie is marked + /// SecurePolicy = Always. Set to false ONLY for local dev stacks running + /// plain HTTP — emits a startup Warning when disabled so the misconfiguration is + /// audible. + /// + 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` class (lines ~15-35) +- `.AddCookie(o => { ... })` with `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides +- `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { })` chained after AddCookie +- `services.AddSingleton, 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; + +/// +/// 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 docs/plans/2026-05-29-auth-alignment-design.md. +/// +public static class ServiceCollectionExtensions +{ + /// Wires cookie authentication, DataProtection key persistence to ConfigDb, + /// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to + /// /login; AJAX/JSON callers receive 401 (handled by the framework's default + /// challenge heuristic). + /// The service collection. + /// The application configuration root. + public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions().Bind(configuration.GetSection(JwtOptions.SectionName)); + services.AddOptions().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName)); + services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); + + services.AddSingleton(); + // Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and + // must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. + services.AddSingleton(); + + services.AddDataProtection() + .PersistKeysToDbContext() + .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(CookieAuthenticationDefaults.AuthenticationScheme) + .Configure, 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, 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(...).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` 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 +/// Anonymous browser GET of a protected route redirects to /login with a ReturnUrl. +[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"); +} + +/// Anonymous AJAX GET of a protected route returns 401 with no Location. +[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(); +} + +/// Anonymous JSON GET of a protected route returns 401. +[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 +/// Creates a TestServer-backed HttpClient that does NOT auto-follow redirects. +/// Used by challenge tests so we can assert on the 302 / Location directly. +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 `` 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 + +``` +Delete it. **Keep** these: +```xml + + +``` +(`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 `` 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. diff --git a/docs/plans/2026-05-29-auth-alignment-plan.md.tasks.json b/docs/plans/2026-05-29-auth-alignment-plan.md.tasks.json new file mode 100644 index 00000000..6fe74c21 --- /dev/null +++ b/docs/plans/2026-05-29-auth-alignment-plan.md.tasks.json @@ -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" +}