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

### 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<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/`:

```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 `<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, 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 |
