af691f3291
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.
274 lines
15 KiB
Markdown
274 lines
15 KiB
Markdown
# 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 |
|