diff --git a/docs/plans/2026-05-29-auth-alignment-design.md b/docs/plans/2026-05-29-auth-alignment-design.md new file mode 100644 index 00000000..2665754d --- /dev/null +++ b/docs/plans/2026-05-29-auth-alignment-design.md @@ -0,0 +1,280 @@ +# Auth/login alignment with ScadaBridge — design + +> **Status:** approved 2026-05-29. Implementation plan to follow via `writing-plans`. +> **Trigger:** browser hitting `http://localhost:9200/` rendered Chrome's `HTTP_RESPONSE_CODE_FAILURE` page because the cookie scheme's `OnRedirectToLogin` event was overridden to return 401 with no body, and the parallel JwtBearer scheme stamped `WWW-Authenticate: Bearer`. ScadaBridge sets `LoginPath` and lets the framework do its built-in browser-vs-AJAX heuristic; OtOpcUa diverged. + +**Goal:** Restore default browser-redirect ergonomics on protected GETs, retire the unused JwtBearer server-side scheme, and externalize cookie config — bringing OtOpcUa's auth structure into parity with ScadaBridge. + +**Architecture:** Single Cookie auth scheme. The JWT keeps minting (via `JwtTokenService`) and validating (in `CookieAuthenticationStateProvider`) as the **cookie payload only**; no `AddJwtBearer`, no parallel `Authorization: Bearer` validation. Cookie config (`Name`, `ExpiryMinutes`, `RequireHttpsCookie`) flows through the existing-but-unused `OtOpcUaCookieOptions` via a `Configure, ILoggerFactory>` PostConfigure step — same pattern ScadaBridge uses. + +**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` only (drop `Microsoft.AspNetCore.Authentication.JwtBearer` from the wiring if its only remaining transitive use disappears with this change). + +--- + +## 1. Architecture + +### Schemes + +| Before | After | +|---|---| +| Cookie (primary) + JwtBearer (parallel) | Cookie only | +| `FallbackPolicy` lists both schemes | `FallbackPolicy` lists Cookie only | +| `OnRedirectToLogin` overridden to 401 | default behavior: 302 for browsers, 401 for AJAX | +| `OnRedirectToAccessDenied` overridden to 403 | default behavior: 302 to `/Account/AccessDenied` (404s today; matches ScadaBridge) | + +### Cookie config — externalized via `OtOpcUaCookieOptions` + +```csharp +public sealed class OtOpcUaCookieOptions +{ + public const string SectionName = "Security:Cookie"; + + public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth"; + public int ExpiryMinutes { get; set; } = 30; + public bool RequireHttpsCookie { get; set; } = true; +} +``` + +Wired into `CookieAuthenticationOptions` via: +```csharp +services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) + .Configure, ILoggerFactory>((cookieOpts, ourOpts, lf) => + { + cookieOpts.Cookie.Name = ourOpts.Value.Name; + cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(ourOpts.Value.ExpiryMinutes); + cookieOpts.SlidingExpiration = true; + cookieOpts.Cookie.SecurePolicy = ourOpts.Value.RequireHttpsCookie + ? CookieSecurePolicy.Always + : CookieSecurePolicy.SameAsRequest; + if (!ourOpts.Value.RequireHttpsCookie) + { + lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning( + "Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. " + + "Cookie travels in cleartext over plain HTTP. Dev-only."); + } + }); +``` + +### Endpoint surface — unchanged + +| Path | Auth | Behavior | +|---|---|---| +| `POST /auth/login` | AllowAnonymous | LDAP auth → SignInAsync(Cookie); JSON callers get 204 / 401 / 503, form posters get 302 + cookie | +| `POST /auth/logout` | RequireAuthorization | SignOutAsync(Cookie) | +| `GET /auth/ping` | AllowAnonymous (handler-returns 200/401) | Polled by Blazor every 60s | +| `POST /auth/token` | RequireAuthorization | Mints JWT for hypothetical external callers (matches ScadaBridge — they keep this even without JwtBearer wired) | + +### Cookie rename + +Old: `OtOpcUa.Auth`. New: `ZB.MOM.WW.OtOpcUa.Auth`. Effect: all sessions in flight at deploy time are invisible to the new handler → users re-prompt for login on next protected GET. No security impact (the old cookie expires per its own sliding window; nothing reads it). + +--- + +## 2. Components + +### Files modified + +| File | Change | +|---|---| +| `src/Server/.../Security/CookieOptions.cs` | Add `RequireHttpsCookie`; change `Name` default to `ZB.MOM.WW.OtOpcUa.Auth` | +| `src/Server/.../Security/ServiceCollectionExtensions.cs` | Drop `using JwtBearer`; delete `ConfigureJwtBearerFromTokenService` class; drop `.AddJwtBearer` + its IPostConfigureOptions registration; drop `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides; add `LoginPath` + `LogoutPath`; add PostConfigure block consuming `OtOpcUaCookieOptions`; remove `JwtBearerDefaults.AuthenticationScheme` from `FallbackPolicy` builder | +| `tests/Server/.../Security.Tests/AuthEndpointsIntegrationTests.cs` | Update the `Set-Cookie` assertion on the login-success test from `OtOpcUa.Auth=` → `ZB.MOM.WW.OtOpcUa.Auth=` | + +### Files NOT modified + +| File | Why | +|---|---| +| `Endpoints/AuthEndpoints.cs` | Endpoint contracts unchanged | +| `Jwt/JwtTokenService.cs` | Still mints JWT into cookie payload | +| `Blazor/CookieAuthenticationStateProvider.cs` | Still polls `/auth/ping` | +| `Ldap/*` | Untouched | +| Razor login page | POST target unchanged | +| `appsettings*.json` | Defaults are production-safe; no required config edit | + +### Tests added + +Single new file or appended class in `tests/Server/.../Security.Tests/`: + +```csharp +public class AuthChallengeTests : AuthEndpointsTestBase +{ + [Fact] + public async Task Root_anonymous_browser_GET_redirects_to_login() + { + var client = NewClient(allowAutoRedirect: false); + client.DefaultRequestHeaders.Accept.ParseAdd("text/html"); + var resp = await client.GetAsync("/", Ct); + resp.StatusCode.ShouldBe(HttpStatusCode.Found); // 302 + resp.Headers.Location!.ToString().ShouldContain("/login"); + resp.Headers.Location.ToString().ShouldContain("ReturnUrl"); + } + + [Fact] + public async Task Root_anonymous_ajax_GET_returns_401() + { + var client = NewClient(allowAutoRedirect: false); + client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + var resp = await client.GetAsync("/", Ct); + resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + resp.Headers.Location.ShouldBeNull(); + } + + [Fact] + public async Task Root_anonymous_json_GET_returns_401() + { + var client = NewClient(allowAutoRedirect: false); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + var resp = await client.GetAsync("/", Ct); + resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } +} +``` + +### Package references + +`src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`: remove `` if grep confirms `JwtTokenService` doesn't itself need it (it uses `Microsoft.IdentityModel.Tokens` for validation parameters, separate package). + +--- + +## 3. Data flow + +### Anonymous browser hits `/` + +``` +Browser → GET / + Accept: text/html + ┌──> AuthN: no cookie → unauthenticated + ├──> AuthZ FallbackPolicy fails + └──> Cookie HandleChallengeAsync: + - Accept: text/html → browser + - 302 Location: /login?ReturnUrl=%2F +Browser → GET /login ← redirect followed; login page renders (AllowAnonymous) +[user submits form] +Browser → POST /auth/login Content-Type: application/x-www-form-urlencoded + ─── LoginAsync: + - LDAP authenticate + - SignInAsync(Cookie) + - Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... + - 302 Location: / (or ReturnUrl) +Browser → GET / cookie present → AuthZ passes → 200 + Razor render +``` + +### XHR / fetch hits a protected endpoint without cookie + +``` +fetch('/api/something') Accept: application/json + X-Requested-With: XMLHttpRequest + ┌──> AuthN: no cookie → unauthenticated + ├──> AuthZ FallbackPolicy fails + └──> Cookie HandleChallengeAsync: + - not text/html → API client + - 401 (no body, no Location) +``` + +The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — no custom event handler needed. + +### Logout + +``` +fetch('/auth/logout', POST) cookie present + ─── LogoutAsync (RequireAuthorization passes): + - SignOutAsync(Cookie) + - Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=; expires=... + - 204 (or browser-form: 302 /login) +``` + +### Old cookie ignored + +Browser holds stale `OtOpcUa.Auth` from a session that predates the deploy. Cookie scheme is now configured for `ZB.MOM.WW.OtOpcUa.Auth` — old cookie is invisible. User treated as anonymous → 302 to `/login`. Old cookie sits in jar until its own sliding window expires (max 30 min); no security risk because nothing reads it. + +### Blazor `/auth/ping` polling + +``` +CookieAuthenticationStateProvider → GET /auth/ping every 60s + cookie present → 200 + cookie expired/missing → 401 +Blazor → invalidates auth state → re-render → root [Authorize] fails + → Cookie HandleChallengeAsync → 302 /login +``` + +Unchanged. + +--- + +## 4. Error handling + +| Surface | Behavior | +|---|---| +| Unknown `Accept` (`*/*`, missing) | Framework default: treated as non-AJAX → 302 to `/login`. Documented behavior, matches ScadaBridge. CLI tools that want JSON-style 401 can set `Accept: application/json`. | +| `LoginAsync` bad creds | JSON: `401`. Form: `302 /login?error=…&returnUrl=…`. Handler-returned, unaffected by middleware changes. | +| `LoginAsync` LDAP throws | `503 ServiceUnavailable`. Handler-returned. | +| `LoginAsync` success | JSON: `204`. Form: `302 /` (or `ReturnUrl`). | +| Cookie expires mid-request | Treated as anonymous → 302 to `/login` (browser) or 401 (AJAX). Active users kept alive by `SlidingExpiration = true`. | +| `RequireHttpsCookie = false` over HTTPS | Cookie marked `SecurePolicy = SameAsRequest`. Misconfiguration risk; startup logs Warning every boot so it's audible. No validator-refused boot — default is `true`; dev compose explicitly opts out. | +| Missing `Security:Cookie` section in config | `.Bind()` no-ops; defaults take over (`Name = ZB.MOM.WW.OtOpcUa.Auth`, `ExpiryMinutes = 30`, `RequireHttpsCookie = true`). Production-safe. | +| `[Authorize(Policy="DriverOperator")]` denied for authenticated non-operator | Cookie handler redirects to default `AccessDeniedPath = "/Account/AccessDenied"` which 404s in OtOpcUa. Matches ScadaBridge; rare enough not to be a P0. Follow-up: add a minimal `/access-denied` Razor page. | + +--- + +## 5. Testing + +### Existing tests pass unchanged + +- `Login_with_invalid_credentials_returns_401` — handler-returned, unaffected +- `Login_when_ldap_throws_returns_503` — handler-returned, unaffected +- `Ping_anonymous_returns_401` — handler-returned, unaffected +- `Ping_after_cookie_login_returns_200` — uses HttpClient cookie container, picks up renamed cookie automatically +- `Login_with_cookie_credentials_returns_204_and_sets_cookie` — needs one assertion update (cookie name) + +### Tests added (3 new) + +- `Root_anonymous_browser_GET_redirects_to_login` — asserts 302 + `Location` contains `/login` + `ReturnUrl` +- `Root_anonymous_ajax_GET_returns_401` — `X-Requested-With: XMLHttpRequest` → 401, no `Location` +- `Root_anonymous_json_GET_returns_401` — `Accept: application/json` → 401 + +### Removed/orphaned tests + +None expected. The explore phase found no test depending on `ConfigureJwtBearerFromTokenService` or the `WWW-Authenticate: Bearer` response. Grep at plan-write time to confirm. + +### Manual smoke (docker-dev stack) + +1. `http://localhost:9200/` anonymously → expect 302 to `/login?ReturnUrl=%2F` (was: Chrome error page) +2. Sign in via the form +3. `http://localhost:9200/` authenticated → expect Razor dashboard +4. DevTools → Application → Cookies → confirm `ZB.MOM.WW.OtOpcUa.Auth` +5. `curl -i http://localhost:9200/` → `302 Found`, Location: `/login?ReturnUrl=%2F` +6. `curl -i -H "Accept: application/json" http://localhost:9200/` → `401 Unauthorized` + +### Verification gates at PR time + +- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero new errors (pre-existing 12 unchanged) +- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green +- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — all green +- Manual Chrome smoke above passes + +--- + +## 6. Sequencing (for plan-writing) + +Single-PR feature, but split into reviewable phases: + +1. **Phase 1 — Options class.** Extend `OtOpcUaCookieOptions` with `RequireHttpsCookie` and new `Name` default. Tests unaffected. +2. **Phase 2 — Wiring rewrite.** Edit `ServiceCollectionExtensions.cs`: drop JwtBearer, drop event overrides, add `LoginPath`/`LogoutPath`, add PostConfigure consumption of `OtOpcUaCookieOptions`. Update the one existing test assertion. Build + existing Security.Tests green. +3. **Phase 3 — New challenge tests.** Add the 3 new redirect/401 tests. +4. **Phase 4 — Package cleanup.** Remove `Microsoft.AspNetCore.Authentication.JwtBearer` from csproj if grep confirms no remaining consumer. +5. **Phase 5 — Manual smoke + commit.** Restart admin-a/admin-b in docker-dev; verify in Chrome. + +--- + +## Decisions table + +| # | Decision | Rationale | +|---|---|---| +| 1 | Drop JwtBearer server-side scheme | No in-repo consumer; brought non-redirect 401 + `WWW-Authenticate: Bearer` to browser GETs | +| 2 | Keep `JwtTokenService` + `/auth/token` | Token-as-cookie-payload is load-bearing for Blazor; `/auth/token` matches ScadaBridge surface | +| 3 | Rename cookie `OtOpcUa.Auth` → `ZB.MOM.WW.OtOpcUa.Auth` | Naming parity with ScadaBridge; one-time forced sign-out acceptable | +| 4 | Externalize via existing `OtOpcUaCookieOptions` + PostConfigure | Mirrors ScadaBridge pattern; fixes pre-existing bug where options class was bound but ignored | +| 5 | Drop both `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides | Restores framework's browser-vs-AJAX heuristic; ScadaBridge does the same | +| 6 | Set `LoginPath = "/login"`, `LogoutPath = "/auth/logout"` | Required for the framework's default redirect to work | +| 7 | Accept 404 on `/Account/AccessDenied` for v1 | Matches ScadaBridge; rare path; follow-up to add minimal page | +| 8 | Warning-log when `RequireHttpsCookie = false` | Audible misconfig signal; same as ScadaBridge |