Files
lmxopcua/docs/plans/2026-05-29-auth-alignment-design.md
T
Joseph Doherty af691f3291 fix(security): correct challenge tests to match framework reality
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.
2026-05-29 07:58:18 -04:00

15 KiB

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<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)
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)

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
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)

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_401X-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.AuthZB.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