# 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_xhr_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); // Framework still writes a Location header alongside the 401 — AJAX clients ignore it. } } ``` **Framework reality vs. earlier hypothesis:** The ASP.NET Core cookie handler's `IsAjaxRequest` heuristic checks ONLY the `X-Requested-With: XMLHttpRequest` header, NOT the `Accept` content type. A request with `Accept: application/json` but no XHR header is classified as a browser → 302. The third test originally proposed (`Root_anonymous_json_GET_returns_401`) was dropped because it tests behavior the framework doesn't have. ScadaBridge accepts the same framework reality (it doesn't override the heuristic either). ### 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 — it looks for `X-Requested-With: XMLHttpRequest`. No custom event handler needed. Note: requests with only `Accept: application/json` (no XHR header) are classified as browsers → 302; AJAX callers should set the XHR header to get 401. ### 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, JSON) | Framework default: treated as non-AJAX → 302 to `/login`. The cookie handler's `IsAjaxRequest` only looks at `X-Requested-With`, NOT `Accept`. CLI tools that want a 401 should set `X-Requested-With: XMLHttpRequest`. | | `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` (the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above) ### 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 |