Compare commits
10 Commits
560b327ee1
...
9e479ce675
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e479ce675 | |||
| af691f3291 | |||
| 453340e71e | |||
| b64d670303 | |||
| c83e9397e6 | |||
| 74b9218a92 | |||
| 532e9933f3 | |||
| ee8add4416 | |||
| bc4fce5fbe | |||
| 7a0b8525a9 |
@@ -33,7 +33,6 @@
|
|||||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||||
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
<PackageVersion Include="MessagePack" Version="2.5.187" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
|
|
||||||
admin-b:
|
admin-b:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -115,7 +115,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
|
|
||||||
driver-a:
|
driver-a:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -129,7 +129,7 @@ services:
|
|||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||||
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4840:4840"
|
- "4840:4840"
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ services:
|
|||||||
Cluster__PublicHostname: "driver-b"
|
Cluster__PublicHostname: "driver-b"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4841:4840"
|
- "4841:4840"
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4842:4840"
|
- "4842:4840"
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4843:4840"
|
- "4843:4840"
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4844:4840"
|
- "4844:4840"
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ services:
|
|||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__DevStubMode: "true"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
|
||||||
ports:
|
ports:
|
||||||
- "4845:4840"
|
- "4845:4840"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,652 @@
|
|||||||
|
# Auth/login alignment with ScadaBridge — implementation plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use `superpowers-extended-cc:executing-plans` or `superpowers-extended-cc:subagent-driven-development` to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Match ScadaBridge's single-Cookie auth pattern: drop the unused JwtBearer parallel scheme, restore the framework's default browser-vs-AJAX challenge heuristic, and externalize cookie config through the existing-but-unused `OtOpcUaCookieOptions`.
|
||||||
|
|
||||||
|
**Architecture:** Cookie-only auth. `JwtTokenService` keeps minting JWTs as the cookie payload (Blazor circuit hydration depends on it). Cookie name + idle timeout + HTTPS policy flow through `OtOpcUaCookieOptions` via a `Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>` PostConfigure step. Endpoint surface (`/auth/login`, `/auth/logout`, `/auth/ping`, `/auth/token`) unchanged.
|
||||||
|
|
||||||
|
**Tech stack:** .NET 10 / ASP.NET Core / `Microsoft.AspNetCore.Authentication.Cookies` / xUnit v3 + Shouldly / `Microsoft.AspNetCore.TestHost.TestServer`.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-05-29-auth-alignment-design.md` (commit `bc4fce5`). Each task below cites the design section it implements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (Options class)
|
||||||
|
└─► Task 2 (Wiring rewrite + test assertion update)
|
||||||
|
├─► Task 3 (3 new challenge tests)
|
||||||
|
└─► Task 4 (csproj cleanup)
|
||||||
|
└─► Task 5 (manual smoke + final commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks 3 and 4 are parallelizable (disjoint files).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — Extend `OtOpcUaCookieOptions`
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** none (Task 2 depends on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs`
|
||||||
|
|
||||||
|
**Implements design:** Section 1 (Architecture, "Cookie config — externalized") + Section 2 (Components, file table row 1).
|
||||||
|
|
||||||
|
### Step 1: Replace file contents
|
||||||
|
|
||||||
|
Current file (12 lines):
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
public sealed class OtOpcUaCookieOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the cookie name.</summary>
|
||||||
|
public string Name { get; set; } = "OtOpcUa.Auth";
|
||||||
|
|
||||||
|
/// <summary>Idle sliding window, in minutes (default 30).</summary>
|
||||||
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```csharp
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||||
|
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||||
|
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OtOpcUaCookieOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||||
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
|
||||||
|
/// <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
|
||||||
|
/// deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||||
|
|
||||||
|
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||||
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
|
||||||
|
/// <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
|
||||||
|
/// plain HTTP — emits a startup Warning when disabled so the misconfiguration is
|
||||||
|
/// audible.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireHttpsCookie { get; set; } = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Build
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
```
|
||||||
|
Expected: 0 errors, 0 warnings.
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "feat(security): extend OtOpcUaCookieOptions with RequireHttpsCookie + ZB.MOM.WW cookie name default"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Lines before / after
|
||||||
|
- Build clean
|
||||||
|
- Commit SHA
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `Name` default is `"ZB.MOM.WW.OtOpcUa.Auth"` (NOT `"OtOpcUa.Auth"`)
|
||||||
|
- [ ] `RequireHttpsCookie` field added with default `true` and XML doc explaining the dev-only opt-out
|
||||||
|
- [ ] `ExpiryMinutes` default unchanged at 30
|
||||||
|
- [ ] `SectionName` constant unchanged
|
||||||
|
- [ ] Build clean
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — Rewrite auth wiring in `ServiceCollectionExtensions.cs`
|
||||||
|
|
||||||
|
**Classification:** standard
|
||||||
|
**Estimated implement time:** ~5 min
|
||||||
|
**Parallelizable with:** none (Tasks 3 and 4 depend on this)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`
|
||||||
|
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs:93`
|
||||||
|
|
||||||
|
**Implements design:** Section 1 + Section 2 file table rows 2 + 3.
|
||||||
|
|
||||||
|
### Step 1: Read current file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Current shape (relevant excerpt):
|
||||||
|
- `using Microsoft.AspNetCore.Authentication.JwtBearer;` at top
|
||||||
|
- `internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) : IPostConfigureOptions<JwtBearerOptions>` class (lines ~15-35)
|
||||||
|
- `.AddCookie(o => { ... })` with `OnRedirectToLogin` / `OnRedirectToAccessDenied` overrides
|
||||||
|
- `.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { })` chained after AddCookie
|
||||||
|
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>()` after the AddAuthentication block
|
||||||
|
- `FallbackPolicy` builder takes both Cookie + JwtBearer schemes
|
||||||
|
|
||||||
|
### Step 2: Replace the file with the new shape
|
||||||
|
|
||||||
|
The full target file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||||
|
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||||
|
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||||
|
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||||
|
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||||
|
/// challenge heuristic).</summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The application configuration root.</param>
|
||||||
|
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||||
|
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
|
||||||
|
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<JwtTokenService>();
|
||||||
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
|
services.AddDataProtection()
|
||||||
|
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||||
|
.SetApplicationName("OtOpcUa");
|
||||||
|
|
||||||
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(o =>
|
||||||
|
{
|
||||||
|
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||||
|
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||||
|
o.LoginPath = "/login";
|
||||||
|
o.LogoutPath = "/auth/logout";
|
||||||
|
o.Cookie.HttpOnly = true;
|
||||||
|
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||||
|
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||||
|
});
|
||||||
|
|
||||||
|
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||||
|
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||||
|
{
|
||||||
|
var v = ourOpts.Value;
|
||||||
|
cookieOpts.Cookie.Name = v.Name;
|
||||||
|
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||||
|
cookieOpts.SlidingExpiration = true;
|
||||||
|
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||||
|
? CookieSecurePolicy.Always
|
||||||
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
|
if (!v.RequireHttpsCookie)
|
||||||
|
{
|
||||||
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||||
|
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||||
|
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||||
|
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization(o =>
|
||||||
|
{
|
||||||
|
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// DriverOperator: may issue Reconnect/Restart commands against live driver instances
|
||||||
|
// from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in
|
||||||
|
// appsettings (e.g. "ot-driver-operator": "DriverOperator").
|
||||||
|
o.AddPolicy("DriverOperator", policy =>
|
||||||
|
policy.RequireRole("DriverOperator", "FleetAdmin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What's gone (vs. the original):
|
||||||
|
- `using Microsoft.AspNetCore.Authentication.JwtBearer;`
|
||||||
|
- `ConfigureJwtBearerFromTokenService` internal class entirely
|
||||||
|
- `.AddJwtBearer(...)` chain after `.AddCookie(...)`
|
||||||
|
- `services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();`
|
||||||
|
- `OnRedirectToLogin` / `OnRedirectToAccessDenied` event overrides
|
||||||
|
- Hardcoded `o.Cookie.Name = "OtOpcUa.Auth"`, `o.SlidingExpiration = true`, `o.ExpireTimeSpan = TimeSpan.FromMinutes(30)`, `o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest`
|
||||||
|
- `JwtBearerDefaults.AuthenticationScheme` from the `FallbackPolicy` builder
|
||||||
|
|
||||||
|
What's added:
|
||||||
|
- `using Microsoft.Extensions.Logging;`
|
||||||
|
- `o.LoginPath = "/login"`, `o.LogoutPath = "/auth/logout"` inside `.AddCookie(...)`
|
||||||
|
- The `services.AddOptions<CookieAuthenticationOptions>(...).Configure<...>(...)` PostConfigure block
|
||||||
|
|
||||||
|
### Step 3: Update the one existing test assertion
|
||||||
|
|
||||||
|
In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` around line 93:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// before
|
||||||
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
||||||
|
// after
|
||||||
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Build + run security tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build clean; all Security.Tests pass (the existing 5 AuthEndpointsIntegrationTests + JwtTokenServiceTests + LdapHelperTests + RoleMapperTests).
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||||
|
src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs \
|
||||||
|
tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "$(cat <<'EOF'
|
||||||
|
refactor(security): drop JwtBearer parallel scheme, externalize cookie config
|
||||||
|
|
||||||
|
Single Cookie auth scheme; framework default challenge restores 302 → /login
|
||||||
|
for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to
|
||||||
|
CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the
|
||||||
|
options class was bound but ignored). Cookie name moves to
|
||||||
|
ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Net LOC change (additions / deletions)
|
||||||
|
- Build clean
|
||||||
|
- Test count run / passed
|
||||||
|
- Commit SHA
|
||||||
|
- Anything unexpected
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `using Microsoft.AspNetCore.Authentication.JwtBearer;` removed
|
||||||
|
- [ ] `ConfigureJwtBearerFromTokenService` class deleted
|
||||||
|
- [ ] `.AddJwtBearer(...)` call deleted
|
||||||
|
- [ ] `IPostConfigureOptions<JwtBearerOptions>` singleton registration deleted
|
||||||
|
- [ ] `OnRedirectToLogin` and `OnRedirectToAccessDenied` overrides deleted
|
||||||
|
- [ ] `LoginPath = "/login"` and `LogoutPath = "/auth/logout"` added inside `.AddCookie(...)`
|
||||||
|
- [ ] PostConfigure block added consuming `OtOpcUaCookieOptions`
|
||||||
|
- [ ] Warning log fires when `RequireHttpsCookie == false`
|
||||||
|
- [ ] `FallbackPolicy` now takes only `CookieAuthenticationDefaults.AuthenticationScheme`
|
||||||
|
- [ ] `DriverOperator` policy unchanged
|
||||||
|
- [ ] Test assertion updated to `ZB.MOM.WW.OtOpcUa.Auth=`
|
||||||
|
- [ ] `dotnet test tests/Server/.../Security.Tests/` all green
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — Add browser-vs-AJAX challenge tests
|
||||||
|
|
||||||
|
**Classification:** small
|
||||||
|
**Estimated implement time:** ~4 min
|
||||||
|
**Parallelizable with:** Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs` (append 3 new test methods + 1 helper)
|
||||||
|
|
||||||
|
**Implements design:** Section 5 "Tests added" + Section 4 "Auth challenge for unknown content type".
|
||||||
|
|
||||||
|
### Context for the implementer
|
||||||
|
|
||||||
|
`AuthEndpointsIntegrationTests` is `IAsyncLifetime`-backed and stands up a `TestServer` with `MapOtOpcUaAuth()` mounted (line 66). The `web.UseEndpoints(e => e.MapOtOpcUaAuth())` wires ONLY the four `/auth/*` endpoints — there is NO root `MapGet("/", ...)` registered. So an anonymous GET to `/` hits the routing pipeline, falls through to a 404 BEFORE auth middleware even challenges.
|
||||||
|
|
||||||
|
**The test harness needs a protected root endpoint.** Add one in `InitializeAsync` inside the `web.UseEndpoints(...)` callback. Then the 3 new tests will exercise the cookie scheme's challenge for that protected route.
|
||||||
|
|
||||||
|
### Step 1: Modify the test host setup
|
||||||
|
|
||||||
|
In `AuthEndpointsIntegrationTests.cs`, change `web.UseEndpoints(...)` (around line 66) from:
|
||||||
|
```csharp
|
||||||
|
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```csharp
|
||||||
|
app.UseEndpoints(e =>
|
||||||
|
{
|
||||||
|
e.MapOtOpcUaAuth();
|
||||||
|
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||||
|
// scheme's challenge heuristic without depending on the full Razor host.
|
||||||
|
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add the three new test methods
|
||||||
|
|
||||||
|
Append at the bottom of the class (before the closing brace), keeping the file's existing summary style and using `TestContext.Current.CancellationToken` via the existing `Ct` property:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||||
|
{
|
||||||
|
var client = NewClientNoRedirect();
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||||
|
req.Headers.Accept.ParseAdd("text/html");
|
||||||
|
var resp = await client.SendAsync(req, Ct);
|
||||||
|
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
||||||
|
resp.Headers.Location.ShouldNotBeNull();
|
||||||
|
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||||
|
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous AJAX GET of a protected route returns 401 with no Location.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_ajax_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous JSON GET of a protected route returns 401.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add the no-redirect client helper
|
||||||
|
|
||||||
|
Right next to the existing `NewClient()` method (line 82):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
||||||
|
/// Used by challenge tests so we can assert on the 302 / Location directly.</summary>
|
||||||
|
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = _server.BaseAddress,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: existing 5 tests still pass + 3 new tests pass = 8+ total green.
|
||||||
|
|
||||||
|
**If `Root_anonymous_browser_GET_redirects_to_login` returns 200 instead of 302**: HttpClient is still auto-following redirects. Two fixes to try in order:
|
||||||
|
1. Confirm `NewClientNoRedirect` uses `_server.CreateHandler()` (not `CreateClient()`).
|
||||||
|
2. If still wrong, swap to: `var handler = new HttpClientHandler { AllowAutoRedirect = false };` — but TestServer doesn't expose HttpClientHandler directly. The `CreateHandler()` path SHOULD return a non-redirecting handler; if it doesn't, the implementation may need a `DelegatingHandler` wrapper.
|
||||||
|
|
||||||
|
**If `Root_anonymous_browser_GET_redirects_to_login` returns 401 instead of 302**: the cookie scheme isn't classifying `Accept: text/html` as a browser. Inspect Task 2's changes — `OnRedirectToLogin` may not have been fully removed, OR `LoginPath` was not set, OR an `Accept` parsing issue. Look at the response body — if it's empty + 401, the JwtBearer scheme or the override is still in play.
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "test(security): add browser-vs-AJAX challenge tests for root path"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- 3 new tests + 1 helper + modified InitializeAsync
|
||||||
|
- Build clean
|
||||||
|
- Test count: existing N + 3 new = N+3 green
|
||||||
|
- Commit SHA
|
||||||
|
- Anything unexpected (e.g. redirect-following behavior of `_server.CreateHandler()`)
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `MapGet("/", ...).RequireAuthorization()` added inside `web.UseEndpoints(...)`
|
||||||
|
- [ ] `NewClientNoRedirect()` helper added
|
||||||
|
- [ ] 3 new `[Fact]` methods added with `TestContext.Current.CancellationToken` via the `Ct` property
|
||||||
|
- [ ] Each test asserts on the exact status + Location header (or absence)
|
||||||
|
- [ ] All tests green
|
||||||
|
- [ ] Existing 5 tests still pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — Remove `Microsoft.AspNetCore.Authentication.JwtBearer` package reference
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~2 min
|
||||||
|
**Parallelizable with:** Task 3
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj` (delete one line)
|
||||||
|
- Verify: `Directory.Packages.props` — leave the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ... />` entry in place (other projects may consume it).
|
||||||
|
|
||||||
|
**Implements design:** Section 2 "Package references" + Section 6 phase 4.
|
||||||
|
|
||||||
|
### Step 1: Confirm no remaining consumer in the Security project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer\|JwtBearer" \
|
||||||
|
/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/ \
|
||||||
|
--include="*.cs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: zero matches. (Task 2 removed all uses.) If there are matches, STOP and report — Task 2 was incomplete.
|
||||||
|
|
||||||
|
### Step 2: Remove the PackageReference
|
||||||
|
|
||||||
|
In `src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj`, find this line (currently around line 13):
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||||
|
```
|
||||||
|
Delete it. **Keep** these:
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||||
|
```
|
||||||
|
(`JwtTokenService` consumes those for `TokenValidationParameters` + JWT creation respectively — they're not from the JwtBearer authentication package.)
|
||||||
|
|
||||||
|
### Step 3: Check whether ANY other project still references the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "Microsoft\.AspNetCore\.Authentication\.JwtBearer" \
|
||||||
|
/Users/dohertj2/Desktop/OtOpcUa/src/ /Users/dohertj2/Desktop/OtOpcUa/tests/ \
|
||||||
|
--include="*.csproj"
|
||||||
|
```
|
||||||
|
|
||||||
|
If zero results: also remove the `<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" ...>` line from `Directory.Packages.props` (search for it). If one or more other projects still reference it, leave `Directory.Packages.props` alone.
|
||||||
|
|
||||||
|
### Step 4: Restore + build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/
|
||||||
|
dotnet build ZB.MOM.WW.OtOpcUa.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 NEW errors. The known pre-existing 12 errors (OpcUaServer.Tests + Runtime.Tests + AbLegacy.Cli + S7.Cli) remain unchanged.
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add \
|
||||||
|
src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj \
|
||||||
|
Directory.Packages.props # only if you also removed it from Directory.Packages.props
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "chore(security): drop Microsoft.AspNetCore.Authentication.JwtBearer (unused)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If only the csproj changed: omit `Directory.Packages.props` from the add.
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- Was Directory.Packages.props also touched? Justify based on whether other projects still reference the package.
|
||||||
|
- Build clean (0 new errors)
|
||||||
|
- Commit SHA
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] Confirmed zero `Microsoft.AspNetCore.Authentication.JwtBearer` or `JwtBearer` matches in `src/Server/ZB.MOM.WW.OtOpcUa.Security/**/*.cs` before deletion
|
||||||
|
- [ ] PackageReference removed from Security.csproj
|
||||||
|
- [ ] `Microsoft.IdentityModel.Tokens` and `System.IdentityModel.Tokens.Jwt` kept
|
||||||
|
- [ ] Directory.Packages.props touched ONLY if no other project consumes the package
|
||||||
|
- [ ] Full solution build adds zero new errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — Manual smoke + final commit
|
||||||
|
|
||||||
|
**Classification:** trivial
|
||||||
|
**Estimated implement time:** ~3 min
|
||||||
|
**Parallelizable with:** none
|
||||||
|
|
||||||
|
**Files:** none (verification + optional cleanup commit)
|
||||||
|
|
||||||
|
**Implements design:** Section 5 "Manual smoke" + Section 6 phase 5.
|
||||||
|
|
||||||
|
### Step 1: Restart the docker-dev cluster
|
||||||
|
|
||||||
|
The admin nodes need to pick up the new `Microsoft.AspNetCore.TestHost`-side code path AND the new cookie name. Since the in-cluster admin processes run a prior build, force a rebuild + recreate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dohertj2/Desktop/OtOpcUa
|
||||||
|
docker compose -f docker-dev/docker-compose.yml up -d --build admin-a admin-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait ~15 s for warm-up. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-dev/docker-compose.yml ps admin-a admin-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Both should show `Up` and `(healthy)` (or `Up` if no healthcheck).
|
||||||
|
|
||||||
|
### Step 2: curl smoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Anonymous browser-shaped GET → 302 to /login with ReturnUrl
|
||||||
|
curl -i -H "Accept: text/html" http://localhost:9200/ 2>&1 | head -12
|
||||||
|
# Expected: HTTP/1.1 302 Found, Location: /login?ReturnUrl=%2F
|
||||||
|
|
||||||
|
# Anonymous AJAX GET → 401
|
||||||
|
curl -i -H "X-Requested-With: XMLHttpRequest" http://localhost:9200/ 2>&1 | head -8
|
||||||
|
# Expected: HTTP/1.1 401 Unauthorized
|
||||||
|
|
||||||
|
# Anonymous JSON GET → 401
|
||||||
|
curl -i -H "Accept: application/json" http://localhost:9200/ 2>&1 | head -8
|
||||||
|
# Expected: HTTP/1.1 401 Unauthorized
|
||||||
|
|
||||||
|
# Login form → 302 with Set-Cookie ZB.MOM.WW.OtOpcUa.Auth
|
||||||
|
curl -i -X POST -d "username=alice&password=alice" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
http://localhost:9200/auth/login 2>&1 | head -15
|
||||||
|
# Expected: HTTP/1.1 302 Found, Set-Cookie: ZB.MOM.WW.OtOpcUa.Auth=... (the test stub user may differ — check docker-compose's GLAuth seed for a valid LDAP creds pair)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Chrome smoke (via the macbook browser instance from earlier in the session)
|
||||||
|
|
||||||
|
1. Open `http://localhost:9200/` — should redirect to `/login?ReturnUrl=%2F` (not Chrome's error page)
|
||||||
|
2. Sign in via the form
|
||||||
|
3. DevTools → Application → Cookies → confirm cookie name is `ZB.MOM.WW.OtOpcUa.Auth`
|
||||||
|
4. Navigate to `http://localhost:9200/` again — should render the AdminUI dashboard
|
||||||
|
5. Click logout → confirm redirect back to `/login`
|
||||||
|
|
||||||
|
### Step 4: Optional CLAUDE.md update
|
||||||
|
|
||||||
|
If `CLAUDE.md` mentions the old `OtOpcUa.Auth` cookie name anywhere, update to the new `ZB.MOM.WW.OtOpcUa.Auth`. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "OtOpcUa\.Auth" /Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If matches: update them, otherwise skip.
|
||||||
|
|
||||||
|
### Step 5: Final commit (only if Step 4 changed CLAUDE.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa add CLAUDE.md
|
||||||
|
git -C /Users/dohertj2/Desktop/OtOpcUa commit -m "docs: update cookie name reference in CLAUDE.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output report
|
||||||
|
|
||||||
|
- All 4 curl smoke checks passed?
|
||||||
|
- Chrome smoke passed?
|
||||||
|
- CLAUDE.md changed?
|
||||||
|
- Final SHA on master (if any docs commit)
|
||||||
|
- Commit count since this plan started (vs `bc4fce5`)
|
||||||
|
|
||||||
|
### Self-review checklist
|
||||||
|
|
||||||
|
- [ ] `docker compose up -d --build admin-a admin-b` succeeded
|
||||||
|
- [ ] All 4 curl smoke checks return expected status codes
|
||||||
|
- [ ] Chrome smoke shows redirect to `/login`, then dashboard after auth
|
||||||
|
- [ ] Cookie name in DevTools matches `ZB.MOM.WW.OtOpcUa.Auth`
|
||||||
|
- [ ] No new commits left uncommitted in the working tree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification gates (apply at end of every task)
|
||||||
|
|
||||||
|
- `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Security/` — 0 errors
|
||||||
|
- `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` — all green (existing + new)
|
||||||
|
- `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — no NEW errors beyond the 12 pre-existing
|
||||||
|
- No untracked files staged accidentally (especially `sql_login.txt`, `pki/`, doc-fix artifacts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk hot-spots for reviewers
|
||||||
|
|
||||||
|
1. **TestServer's no-redirect HttpClient.** The plan assumes `new HttpClient(_server.CreateHandler()) { BaseAddress = _server.BaseAddress }` does NOT auto-follow redirects. If it does, the `Root_anonymous_browser_GET_redirects_to_login` test fails with 200 instead of 302. Fix path documented in Task 3 Step 4.
|
||||||
|
2. **Framework default of `Accept: */*` → 302.** Curl's default Accept header is `*/*`, which the framework classifies as browser → 302. Documented behavior, mirrors ScadaBridge; reviewers should not flag the smoke step that uses `Accept: text/html` as redundant — it's the explicit "browser" assertion.
|
||||||
|
3. **Cookie rename invalidates sessions.** The deploy effectively logs every currently-signed-in user out. Document in commit body; the cluster was just restarted on the new API key anyway, so the timing is opportune.
|
||||||
|
4. **`Directory.Packages.props` change is conditional.** Don't touch it if other projects still consume the JwtBearer package. Task 4 has explicit grep guard.
|
||||||
|
5. **`/Account/AccessDenied` 404.** Authenticated users hitting a `DriverOperator`-only route now get a generic 404 page instead of a clean access-denied message. Documented design choice; follow-up to add a Razor page if UX feedback demands it.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-29-auth-alignment-plan.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 1: Extend OtOpcUaCookieOptions", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 2: Rewrite auth wiring + update cookie-name assertion", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Add browser-vs-AJAX challenge tests", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 4: Remove JwtBearer package reference", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 5, "subject": "Task 5: Manual smoke + final commit", "status": "pending", "blockedBy": [3, 4]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-29T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auth-cookie configuration bound from <c>Security:Cookie</c>. Consumed by a
|
||||||
|
/// <c>Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory></c> step inside
|
||||||
|
/// <c>AddOtOpcUaAuth</c> that copies the values onto <c>CookieAuthenticationOptions</c>.
|
||||||
|
/// </summary>
|
||||||
public sealed class OtOpcUaCookieOptions
|
public sealed class OtOpcUaCookieOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>Configuration section name (<c>Security:Cookie</c>).</summary>
|
||||||
public const string SectionName = "Security:Cookie";
|
public const string SectionName = "Security:Cookie";
|
||||||
|
|
||||||
/// <summary>Gets or sets the cookie name.</summary>
|
/// <summary>
|
||||||
public string Name { get; set; } = "OtOpcUa.Auth";
|
/// Auth cookie name. Default uses the <c>ZB.MOM.WW</c> convention; mirrors ScadaBridge's
|
||||||
|
/// <c>ZB.MOM.WW.ScadaBridge.Auth</c>. Changing this invalidates existing sessions on next
|
||||||
|
/// deploy.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = "ZB.MOM.WW.OtOpcUa.Auth";
|
||||||
|
|
||||||
/// <summary>Idle sliding window, in minutes (default 30).</summary>
|
/// <summary>Idle sliding-window length in minutes (default 30).</summary>
|
||||||
public int ExpiryMinutes { get; set; } = 30;
|
public int ExpiryMinutes { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Require HTTPS for the auth cookie. Default <c>true</c>: cookie is marked
|
||||||
|
/// <c>SecurePolicy = Always</c>. Set to <c>false</c> ONLY for local dev stacks running
|
||||||
|
/// plain HTTP — emits a startup Warning when disabled so the misconfiguration is
|
||||||
|
/// audible.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireHttpsCookie { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||||
@@ -12,35 +13,20 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Security;
|
namespace ZB.MOM.WW.OtOpcUa.Security;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <see cref="JwtTokenService"/> from the real DI container at runtime so the bearer
|
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the
|
||||||
/// pipeline's <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> stay in
|
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge
|
||||||
/// lock-step with <see cref="JwtTokenService.BuildValidationParameters"/>. Replaces the prior
|
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
|
||||||
/// <c>services.BuildServiceProvider()</c> antipattern (ASP0000) that built a captive provider
|
|
||||||
/// from inside <c>.AddJwtBearer</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
|
|
||||||
: IPostConfigureOptions<JwtBearerOptions>
|
|
||||||
{
|
|
||||||
/// <summary>Configures JWT bearer options from the token service.</summary>
|
|
||||||
/// <param name="name">The options name.</param>
|
|
||||||
/// <param name="options">The JWT bearer options to configure.</param>
|
|
||||||
public void PostConfigure(string? name, JwtBearerOptions options)
|
|
||||||
{
|
|
||||||
if (name != JwtBearerDefaults.AuthenticationScheme) return;
|
|
||||||
options.TokenValidationParameters = tokenService.BuildValidationParameters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wires cookie+JWT hybrid authentication. Cookies are the primary scheme for browser-facing
|
/// Wires cookie authentication, DataProtection key persistence to ConfigDb,
|
||||||
/// Blazor + Razor flows; JWT bearer is layered in for external API consumers (OPC UA client
|
/// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to
|
||||||
/// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive
|
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
|
||||||
/// failover between nodes.
|
/// challenge heuristic).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
/// <param name="configuration">The application configuration.</param>
|
/// <param name="configuration">The application configuration root.</param>
|
||||||
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
|
||||||
@@ -50,8 +36,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<JwtTokenService>();
|
services.AddSingleton<JwtTokenService>();
|
||||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||||
// The driver-branch in Host/Program.cs registers the same way; consistent lifetime
|
|
||||||
// across both paths keeps ValidateScopes-on-Build clean.
|
|
||||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
services.AddDataProtection()
|
services.AddDataProtection()
|
||||||
@@ -61,32 +45,42 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(o =>
|
.AddCookie(o =>
|
||||||
{
|
{
|
||||||
o.Cookie.Name = "OtOpcUa.Auth";
|
// Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration
|
||||||
|
// are bound from OtOpcUaCookieOptions in the PostConfigure block below.
|
||||||
|
o.LoginPath = "/login";
|
||||||
|
o.LogoutPath = "/auth/logout";
|
||||||
o.Cookie.HttpOnly = true;
|
o.Cookie.HttpOnly = true;
|
||||||
o.Cookie.SameSite = SameSiteMode.Strict;
|
o.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
// No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's
|
||||||
o.SlidingExpiration = true;
|
// built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX).
|
||||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
|
});
|
||||||
o.Events.OnRedirectToLogin = ctx =>
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
o.Events.OnRedirectToAccessDenied = ctx =>
|
|
||||||
{
|
|
||||||
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { /* parameters set by IPostConfigureOptions below */ });
|
|
||||||
|
|
||||||
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
|
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
|
||||||
|
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<OtOpcUaCookieOptions>, ILoggerFactory>((cookieOpts, ourOpts, lf) =>
|
||||||
|
{
|
||||||
|
var v = ourOpts.Value;
|
||||||
|
cookieOpts.Cookie.Name = v.Name;
|
||||||
|
cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes);
|
||||||
|
cookieOpts.SlidingExpiration = true;
|
||||||
|
cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie
|
||||||
|
? CookieSecurePolicy.Always
|
||||||
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
|
if (!v.RequireHttpsCookie)
|
||||||
|
{
|
||||||
|
lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning(
|
||||||
|
"Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " +
|
||||||
|
"SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " +
|
||||||
|
"Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
services.AddAuthorization(o =>
|
services.AddAuthorization(o =>
|
||||||
{
|
{
|
||||||
o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
|
o.FallbackPolicy = new AuthorizationPolicyBuilder(
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -63,7 +64,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseEndpoints(e => e.MapOtOpcUaAuth());
|
app.UseEndpoints(e =>
|
||||||
|
{
|
||||||
|
e.MapOtOpcUaAuth();
|
||||||
|
// Protected root used by AuthChallengeTests below — exercises the cookie
|
||||||
|
// scheme's challenge heuristic without depending on the full Razor host.
|
||||||
|
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
@@ -81,6 +88,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
|
|
||||||
private HttpClient NewClient() => _server.CreateClient();
|
private HttpClient NewClient() => _server.CreateClient();
|
||||||
|
|
||||||
|
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
||||||
|
/// Used by challenge tests so we can assert on the 302 + Location directly.</summary>
|
||||||
|
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = _server.BaseAddress,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
|
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
|
||||||
@@ -90,7 +104,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||||
|
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||||
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth="));
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
||||||
@@ -170,12 +184,43 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|||||||
loginResponse.EnsureSuccessStatusCode();
|
loginResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
|
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
|
||||||
|
logoutReq.Headers.Accept.ParseAdd("application/json");
|
||||||
AttachCookies(logoutReq, loginResponse);
|
AttachCookies(logoutReq, loginResponse);
|
||||||
var response = await client.SendAsync(logoutReq, Ct);
|
var response = await client.SendAsync(logoutReq, Ct);
|
||||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||||
|
|
||||||
response.Headers.GetValues("Set-Cookie")
|
response.Headers.GetValues("Set-Cookie")
|
||||||
.ShouldContain(c => c.StartsWith("OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
|
.ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
||||||
|
{
|
||||||
|
var client = NewClientNoRedirect();
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||||
|
req.Headers.Accept.ParseAdd("text/html");
|
||||||
|
var resp = await client.SendAsync(req, Ct);
|
||||||
|
|
||||||
|
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
||||||
|
resp.Headers.Location.ShouldNotBeNull();
|
||||||
|
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||||
|
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser
|
||||||
|
/// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
|
||||||
|
/// heuristic). The framework still writes a <c>Location</c> header alongside the 401;
|
||||||
|
/// AJAX clients ignore it.</summary>
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
|
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
|
||||||
|
|||||||
Reference in New Issue
Block a user