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:
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user