3ddb6876db
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
128 lines
5.8 KiB
C#
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));
|
|
}
|
|
}
|