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
@@ -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);
}