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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user