fix(security): first-party JwtBearer with HS256 pinned; /auth/token mints for a cookie session only

The hand-rolled bearer handler is replaced by AddJwtBearer (Microsoft.AspNetCore.Authentication.JwtBearer
10.0.7, pinned on the same servicing line as the other AspNetCore packages). Validation parameters come
from JwtTokenService.BuildValidationParameters, which now pins ValidAlgorithms = HS256 for both the
bearer scheme and TryValidate; MapInboundClaims is off so the minted Role/zb:username keys resolve.
/auth/token requires the cookie scheme, so a bearer can no longer mint a fresh token for itself.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:35:54 -04:00
parent 4d17b346c6
commit 3ddb6876db
10 changed files with 187 additions and 126 deletions
+4
View File
@@ -35,6 +35,10 @@
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
<PackageVersion Include="libplctag" Version="1.5.2" />
<PackageVersion Include="MessagePack" Version="2.5.301" />
<!-- First-party bearer validation for /auth/token's JWTs (R03:2.4, Lane C fix round 1). Same 10.0.x servicing
line as the other Microsoft.AspNetCore.* pins; its Microsoft.IdentityModel.* dependencies resolve UP to the
8.11.0 pins below (no downgrade, no NU1605). -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.EntityFrameworkCore" Version="10.0.7" />
+8 -5
View File
@@ -6,11 +6,14 @@
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
> than in a separate Admin process. The Admin UI uses a **single Cookie
> authentication scheme** — there is no `AddJwtBearer` pipeline. The
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
> than in a separate Admin process. The Admin UI session is a **Cookie**
> scheme; the cookie stores the `ClaimsPrincipal` directly. JWT is **opt-in by
> `Security:Jwt:SigningKey`** (validated at host start, ≥32 bytes): with a key,
> `/auth/token` mints an HS256 token for a **cookie session only** (a bearer
> cannot renew itself), and a first-party `AddJwtBearer` scheme (`Bearer`,
> `ValidAlgorithms = HS256`, parameters from `JwtTokenService.BuildValidationParameters`)
> accepts it on `/api/*` and every `RequireAuthorization` endpoint through the
> `OtOpcUa.CookieOrBearer` default policy scheme. Without a key neither exists. DataProtection
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
> so cookies survive failover between admin-role nodes.
>
@@ -1,99 +0,0 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
public static class OtOpcUaAuthSchemes
{
/// <summary>
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints, through
/// <see cref="JwtTokenService.BuildValidationParameters"/>. Registered only when
/// <see cref="JwtOptions.IsConfigured"/>.
/// </summary>
public const string Bearer = "Bearer";
/// <summary>
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
/// </summary>
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
/// <param name="context">The request.</param>
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
public static bool HasBearerToken(HttpContext context) =>
TryGetBearerToken(context.Request.Headers.Authorization, out _);
internal static bool TryGetBearerToken(StringValues authorization, out string token)
{
token = string.Empty;
var header = authorization.ToString();
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
token = header[prefix.Length..].Trim();
return token.Length > 0;
}
}
/// <summary>
/// Validates an <c>Authorization: Bearer</c> token minted by <c>/auth/token</c> (R03:2.4). The JWT
/// feature used to be a dead end: tokens were issued and nothing in the product accepted them. This
/// handler is the consuming side, built on <see cref="JwtTokenService.TryValidate"/> so the validation
/// parameters can never drift from the minting side (<see cref="JwtTokenService.BuildValidationParameters"/>
/// sets <c>RoleClaimType</c>/<c>NameClaimType</c> to the minted keys, so <c>[Authorize(Roles)]</c> and
/// <c>IsInRole</c> resolve).
/// </summary>
/// <remarks>
/// Fail closed: no header ⇒ <c>NoResult</c> (the cookie scheme is not consulted by this handler — the
/// policy scheme selects one or the other); a malformed, expired, tampered or wrong-issuer token ⇒
/// <c>Fail</c>, never a fall-through to the cookie. A direct <c>AddJwtBearer</c> needs the
/// <c>Microsoft.AspNetCore.Authentication.JwtBearer</c> package, which the central package list does
/// not carry; this handler is ~40 lines over the already-referenced <c>System.IdentityModel.Tokens.Jwt</c>
/// and is a drop-in to replace once that package is pinned.
/// </remarks>
public sealed class JwtBearerAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly JwtTokenService _jwt;
/// <summary>Initializes a new instance of the <see cref="JwtBearerAuthenticationHandler"/> class.</summary>
/// <param name="options">The scheme options monitor.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
/// <param name="jwt">The token service whose validation parameters mirror its minting side.</param>
public JwtBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
JwtTokenService jwt)
: base(options, logger, encoder)
{
_jwt = jwt;
}
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!OtOpcUaAuthSchemes.TryGetBearerToken(Request.Headers.Authorization, out var token))
return Task.FromResult(AuthenticateResult.NoResult());
if (!_jwt.TryValidate(token, out var principal) || principal is null)
return Task.FromResult(AuthenticateResult.Fail("Invalid bearer token."));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
/// <inheritdoc />
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
Response.Headers.WWWAuthenticate = OtOpcUaAuthSchemes.Bearer;
return Task.CompletedTask;
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
public static class OtOpcUaAuthSchemes
{
/// <summary>
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints: the first-party
/// <c>AddJwtBearer</c> handler over <see cref="JwtTokenService.BuildValidationParameters"/>.
/// Registered only when <see cref="JwtOptions.IsConfigured"/>.
/// </summary>
public const string Bearer = "Bearer";
/// <summary>
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
/// </summary>
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
/// <param name="context">The request.</param>
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
public static bool HasBearerToken(HttpContext context) =>
TryGetBearerToken(context.Request.Headers.Authorization, out _);
internal static bool TryGetBearerToken(StringValues authorization, out string token)
{
token = string.Empty;
var header = authorization.ToString();
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
token = header[prefix.Length..].Trim();
return token.Length > 0;
}
}
@@ -24,6 +24,17 @@ public static class AuthEndpoints
/// <summary>Response for a token issue request.</summary>
public sealed record TokenResponse(string Token);
/// <summary>
/// The <c>/auth/token</c> policy: an authenticated principal from the COOKIE scheme. Naming the scheme
/// makes the authorization middleware authenticate that scheme alone, so an <c>Authorization: Bearer</c>
/// header is never consulted here (with login disabled the auto-login handler sits under the same
/// scheme name, so the dev rig is unchanged).
/// </summary>
internal static readonly AuthorizationPolicy CookieSessionOnly =
new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
/// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns>
@@ -40,7 +51,10 @@ public static class AuthEndpoints
// Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured
// endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call.
if (app.ServiceProvider.GetRequiredService<IOptions<JwtOptions>>().Value.IsConfigured)
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
// COOKIE scheme only (fix round 1 / I2): the default scheme also accepts a bearer, and a bearer
// that could mint a fresh token would renew itself forever — outliving its own expiry and the
// user's LDAP membership. A token is minted only for an interactive (cookie) session.
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(CookieSessionOnly);
else
app.ServiceProvider.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
@@ -25,24 +25,12 @@ public sealed class JwtTokenService
/// <summary>
/// Role claim type used in the JWT payload.
/// <para>
/// <b>Issued-only / no internal JwtBearer scheme:</b> OtOpcUa uses a single Cookie
/// authentication scheme; the JWT is minted by the <c>/auth/token</c> endpoint and
/// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no
/// <c>AddJwtBearer</c> pipeline in OtOpcUa — the cookie stores the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> directly. Because no internal
/// bearer validation path exists, the short "Role" key is intentionally used here rather
/// than the long <see cref="ClaimTypes.Role"/> URI; external consumers receive exactly the
/// key they expect.
/// </para>
/// <para>
/// <b>If a JwtBearer scheme is ever added:</b> the
/// <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> passed to
/// <c>AddJwtBearer</c> MUST set <c>RoleClaimType = JwtTokenService.RoleClaimType</c> (and
/// <c>NameClaimType = JwtTokenService.UsernameClaimType</c>) so that
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve correctly.
/// <see cref="BuildValidationParameters"/> is already wired to do this and MUST be used
/// rather than constructing <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/>
/// ad hoc.
/// The short "Role" key is what <see cref="Issue"/> mints and what external consumers of the token
/// expect. The internal bearer scheme (<c>AddJwtBearer</c> in <c>AddOtOpcUaAuth</c>) validates with
/// <see cref="BuildValidationParameters"/>, which sets <c>RoleClaimType = RoleClaimType</c> and
/// <c>NameClaimType = UsernameClaimType</c>, and turns <c>MapInboundClaims</c> off, so
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve against this key. Never
/// construct <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> ad hoc.
/// </para>
/// </summary>
public const string RoleClaimType = "Role";
@@ -150,5 +138,9 @@ public sealed class JwtTokenService
// if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale.
RoleClaimType = RoleClaimType,
NameClaimType = UsernameClaimType,
// Pinned to the one algorithm Issue signs with: an unsigned (alg:none) token or one signed with a
// different HMAC variant over the same key is refused on both the bearer and TryValidate paths.
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
RequireSignedTokens = true,
};
}
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
@@ -108,8 +109,21 @@ public static class ServiceCollectionExtensions
});
if (jwtConfigured)
{
authBuilder.AddScheme<AuthenticationSchemeOptions, JwtBearerAuthenticationHandler>(
OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
// First-party JwtBearer (R03:2.4, fix round 1 / I1) — the earlier hand-rolled handler is gone.
// Validation parameters come from the SAME JwtTokenService that mints, so issuer/audience/key/
// claim types can never drift; BuildValidationParameters pins ValidAlgorithms = HS256 (an
// alg:none or a different-HMAC token is refused). MapInboundClaims=false keeps the minted
// "Role"/"zb:username" claim keys verbatim, which RoleClaimType/NameClaimType name. A bad token
// is AuthenticateResult.Fail ⇒ 401 + WWW-Authenticate: Bearer, never a 500.
authBuilder.AddJwtBearer(OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
services.AddOptions<JwtBearerOptions>(OtOpcUaAuthSchemes.Bearer)
.Configure<JwtTokenService>((o, jwt) =>
{
o.TokenValidationParameters = jwt.BuildValidationParameters();
o.MapInboundClaims = false;
o.SaveToken = false;
o.IncludeErrorDetails = false;
});
}
if (disableLogin)
{
@@ -10,6 +10,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
@@ -208,6 +209,98 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
}
/// <summary>
/// Fix round 1 / I1 — the first-party JwtBearer handler pins HS256: an unsigned (<c>alg: none</c>)
/// token carrying the right issuer/audience/roles, and a token signed with a different HMAC
/// variant, both fail CLOSED with a 401 — never a 500, never a pass.
/// </summary>
[Theory]
[InlineData("none")]
[InlineData("HS384")]
public async Task Bearer_with_a_non_HS256_algorithm_is_rejected_with_401(string alg)
{
var token = alg == "none" ? UnsignedToken() : SignedToken(SecurityAlgorithms.HmacSha384);
var client = NewClient();
var req = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(req, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
// Positive control: the same claims signed HS256 with the configured key ARE accepted, so the
// 401 above measured the algorithm and not a broken token shape.
var good = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
good.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", SignedToken(SecurityAlgorithms.HmacSha256));
(await client.SendAsync(good, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Fix round 1 / I2 — <c>/auth/token</c> mints only for a COOKIE session. A bearer token used to
/// be able to mint a fresh token for itself, so a stolen token could renew itself forever and
/// outlive both its own expiry and the user's LDAP membership. Positive control: the cookie
/// session that minted the bearer still gets a token.
/// </summary>
[Fact]
public async Task Auth_token_refuses_a_bearer_only_caller_and_serves_a_cookie_session()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var cookieReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(cookieReq, loginResponse);
var cookieResp = await client.SendAsync(cookieReq, Ct);
cookieResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var bearer = (await cookieResp.Content.ReadFromJsonAsync<JsonElement>(Ct)).GetProperty("token").GetString()!;
// The bearer authenticates elsewhere (positive control on the token itself) …
var root = new HttpRequestMessage(HttpMethod.Get, "/");
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
// … but cannot renew itself.
var renew = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
renew.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
renew.Headers.Add("X-Requested-With", "XMLHttpRequest");
var renewResp = await client.SendAsync(renew, Ct);
renewResp.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
(await renewResp.Content.ReadAsStringAsync(Ct)).ShouldNotContain("token");
}
private const string TestSigningKey = "test-signing-key-with-at-least-32-bytes-of-utf8-content";
private static IEnumerable<System.Security.Claims.Claim> ViewerClaims() =>
[
new(JwtTokenService.UsernameClaimType, "mallory"),
new(JwtTokenService.RoleClaimType, "Viewer"),
];
/// <summary>A token signed with <paramref name="algorithm"/> using the configured key bytes.</summary>
private static string SignedToken(string algorithm)
{
var keyBytes = System.Text.Encoding.UTF8.GetBytes(TestSigningKey);
// The SAME key bytes for every algorithm, so a rejection measures the algorithm pin alone.
var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
issuer: "otopcua-test", audience: "otopcua-test", claims: ViewerClaims(),
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(keyBytes), algorithm));
return new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>An <c>alg: none</c> token with otherwise valid issuer/audience/lifetime/roles.</summary>
private static string UnsignedToken()
{
static string B64(string json) => Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(json);
var exp = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds();
return B64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." +
B64($"{{\"zb:username\":\"mallory\",\"Role\":\"Viewer\",\"iss\":\"otopcua-test\",\"aud\":\"otopcua-test\",\"exp\":{exp}}}") +
".";
}
private async Task<string> LoginAndIssueTokenAsync()
{
var client = NewClient();
@@ -65,7 +65,7 @@ public sealed class JwtConfigurationGateTests
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
bearer.ShouldNotBeNull();
bearer.HandlerType.ShouldBe(typeof(JwtBearerAuthenticationHandler));
bearer.HandlerType.ShouldBe(typeof(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler));
await host.StopAsync(Ct);
}