ASP.NET Core's cookie-handler IsAjaxRequest heuristic only checks X-Requested-With (not Accept). Drop the third test (Accept: application/json was assumed to → 401 but actually → 302) and the Location.ShouldBeNull assertion on the XHR test (framework still writes Location alongside 401; clients ignore it). Renamed _ajax_ → _xhr_ for accuracy. Design doc updated to match.
15 KiB
Auth/login alignment with ScadaBridge — design
Status: approved 2026-05-29. Implementation plan to follow via
writing-plans. Trigger: browser hittinghttp://localhost:9200/rendered Chrome'sHTTP_RESPONSE_CODE_FAILUREpage because the cookie scheme'sOnRedirectToLoginevent was overridden to return 401 with no body, and the parallel JwtBearer scheme stampedWWW-Authenticate: Bearer. ScadaBridge setsLoginPathand 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<IOptions<OtOpcUaCookieOptions>, 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
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:
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<OtOpcUaCookieOptions>, 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/:
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 <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> 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, unaffectedLogin_when_ldap_throws_returns_503— handler-returned, unaffectedPing_anonymous_returns_401— handler-returned, unaffectedPing_after_cookie_login_returns_200— uses HttpClient cookie container, picks up renamed cookie automaticallyLogin_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 +Locationcontains/login+ReturnUrlRoot_anonymous_ajax_GET_returns_401—X-Requested-With: XMLHttpRequest→ 401, noLocation(the originally plannedRoot_anonymous_json_GET_returns_401was 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)
http://localhost:9200/anonymously → expect 302 to/login?ReturnUrl=%2F(was: Chrome error page)- Sign in via the form
http://localhost:9200/authenticated → expect Razor dashboard- DevTools → Application → Cookies → confirm
ZB.MOM.WW.OtOpcUa.Auth curl -i http://localhost:9200/→302 Found, Location:/login?ReturnUrl=%2Fcurl -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 greendotnet 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:
- Phase 1 — Options class. Extend
OtOpcUaCookieOptionswithRequireHttpsCookieand newNamedefault. Tests unaffected. - Phase 2 — Wiring rewrite. Edit
ServiceCollectionExtensions.cs: drop JwtBearer, drop event overrides, addLoginPath/LogoutPath, add PostConfigure consumption ofOtOpcUaCookieOptions. Update the one existing test assertion. Build + existing Security.Tests green. - Phase 3 — New challenge tests. Add the 3 new redirect/401 tests.
- Phase 4 — Package cleanup. Remove
Microsoft.AspNetCore.Authentication.JwtBearerfrom csproj if grep confirms no remaining consumer. - 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 |