diff --git a/docs/plans/2026-05-29-auth-alignment-design.md b/docs/plans/2026-05-29-auth-alignment-design.md index 2665754d..01a0e192 100644 --- a/docs/plans/2026-05-29-auth-alignment-design.md +++ b/docs/plans/2026-05-29-auth-alignment-design.md @@ -110,26 +110,19 @@ public class AuthChallengeTests : AuthEndpointsTestBase } [Fact] - public async Task Root_anonymous_ajax_GET_returns_401() + 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); - 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); + // 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). @@ -171,7 +164,7 @@ fetch('/api/something') Accept: application/json - 401 (no body, no Location) ``` -The cookie handler's built-in `IsAjaxRequest` heuristic is what makes this work — no custom event handler needed. +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 @@ -205,7 +198,7 @@ Unchanged. | 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`. | +| 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`). | @@ -230,7 +223,7 @@ Unchanged. - `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 + (the originally planned `Root_anonymous_json_GET_returns_401` was dropped — see Section 3 framework-reality note above) ### Removed/orphaned tests diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index 45ca5c6c..d3312771 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -207,28 +207,18 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl"); } - /// Anonymous AJAX GET of a protected route returns 401 with no Location. + /// Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser + /// via the X-Requested-With header — the ASP.NET cookie handler's IsAjaxRequest + /// heuristic). The framework still writes a Location header alongside the 401; + /// AJAX clients ignore it. [Fact] - public async Task Root_anonymous_ajax_GET_returns_401() + public async Task Root_anonymous_xhr_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); }