Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtConfigurationGateTests.cs
T
Joseph Doherty 3ddb6876db 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
2026-10-22 13:35:54 -04:00

128 lines
5.8 KiB
C#

using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// R03:2.4 — JWT is opt-in by signing key. Unconfigured: <c>/auth/token</c> is not mapped (it used to
/// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the
/// host refuses to START rather than failing on the first token request.
/// </summary>
public sealed class JwtConfigurationGateTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored()
{
using var host = await StartHostAsync(signingKey: null);
var server = host.GetTestServer();
var client = server.CreateClient();
// The login still works — the cookie path is untouched.
var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
login.StatusCode.ShouldBe(HttpStatusCode.NoContent);
// /auth/token is simply absent (404), not a 500.
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]);
(await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound);
// No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins.
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
(await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull();
var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/");
foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]);
withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage");
(await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
await host.StopAsync(Ct);
}
/// <summary>Positive control for the gate: with a key, the bearer scheme IS registered.</summary>
[Fact]
public async Task With_a_signing_key_the_bearer_scheme_is_registered()
{
using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content");
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
bearer.ShouldNotBeNull();
bearer.HandlerType.ShouldBe(typeof(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler));
await host.StopAsync(Ct);
}
/// <summary>A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token.</summary>
[Fact]
public async Task A_short_signing_key_fails_host_start()
{
var ex = await Should.ThrowAsync<OptionsValidationException>(async () =>
{
using var host = await StartHostAsync(signingKey: "too-short");
});
ex.Message.ShouldContain("SigningKey");
}
private static async Task<IHost> StartHostAsync(string? signingKey)
{
var dbName = $"jwt-gate-{Guid.NewGuid():N}";
var settings = new Dictionary<string, string?>();
if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var host = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddRouting();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, AlwaysAliceLdap>();
services.AddSingleton<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e =>
{
e.MapOtOpcUaAuth();
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
});
});
})
.Build();
await host.StartAsync(Ct);
return host;
}
private sealed class AlwaysAliceLdap : ILdapAuthService
{
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
Task.FromResult(new LdapAuthResult(
Success: password == "valid-password", DisplayName: "Alice", Username: username,
Groups: [], Roles: [], Error: null));
}
}