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
@@ -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"/>