fix(security): validate the API-key pepper at start, finish JWT bearer, circuit-aware audit actor
- ApiKeyOptionsValidator (ValidateOnStart on any driver/admin node): a blank or placeholder Security:ApiKeys:Pepper fails host start instead of rejecting every key after a green boot (R03:2.3). - JWT is finished and opt-in by key (R03:2.4): with Security:Jwt:SigningKey set, a Bearer scheme (JwtBearerAuthenticationHandler over JwtTokenService.TryValidate / BuildValidationParameters, fail closed) accepts the tokens /auth/token mints; the default scheme is a cookie-or-bearer policy scheme and the fallback policy names it, so /api/* and every RequireAuthorization endpoint take a browser session OR a machine token. JwtOptions are validated at start (JwtOptionsValidator). Without a key, /auth/token is not mapped (404, not the former constructor 500) and no bearer scheme exists. A direct AddJwtBearer needs Microsoft.AspNetCore.Authentication.JwtBearer in Directory.Packages.props (not this lane's file) — the handler is a drop-in to swap. - AuthenticationStateAuditActorAccessor is the registered IAuditActorAccessor: the Blazor circuit's principal first, HttpContext as the fallback (R03:2.5). HttpAuditActorAccessor shares its resolver. - CookieAuthenticationStateProvider (unregistered, fire-and-forget ping loop) is deleted (R03:1.5/2.5). - /auth/login logs the LDAP exception at Warning before answering 503 (R03:2.7). - Admin operations reply with a fixed message + correlation id instead of ex.Message (R03:4.1). Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R03:4.1 — admin operations echoed <c>ex.Message</c> to the UI, so a SqlException's connection-string
|
||||
/// fragment reached any ConfigEditor browser session. The reply is now a fixed sentence plus the request's
|
||||
/// correlation id; the detail stays in the central log.
|
||||
/// </summary>
|
||||
public sealed class AdminOperationsActorServerFaultMessageTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private const string Secret = "Server=sql-prod-01;Password=hunter2";
|
||||
|
||||
[Fact]
|
||||
public void StartDeployment_server_fault_reply_carries_the_correlation_id_and_not_the_exception_text()
|
||||
{
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(new ThrowingDbFactory(), coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
var correlationId = CorrelationId.NewId();
|
||||
|
||||
actor.Tell(new StartDeployment("joe", correlationId));
|
||||
|
||||
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldNotContain("hunter2");
|
||||
reply.Message.ShouldNotContain("sql-prod-01");
|
||||
reply.Message.ShouldContain(correlationId.ToString()); // the operator can find the log line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDriverConnect_probe_throw_reply_carries_the_correlation_id_and_not_the_exception_text()
|
||||
{
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), coordinator.Ref, new IDriverProbe[] { new ThrowingProbe() }));
|
||||
var correlationId = Guid.NewGuid();
|
||||
|
||||
actor.Tell(new TestDriverConnect("Throwing", "{}", 5, correlationId));
|
||||
|
||||
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
|
||||
reply.Ok.ShouldBeFalse();
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldNotContain("hunter2");
|
||||
reply.Message.ShouldContain(correlationId.ToString());
|
||||
}
|
||||
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() =>
|
||||
throw new InvalidOperationException("Cannot open database: " + Secret);
|
||||
}
|
||||
|
||||
private sealed class ThrowingProbe : IDriverProbe
|
||||
{
|
||||
public string DriverType => "Throwing";
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("probe blew up: " + Secret);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.3 — an unset or placeholder pepper must fail host start, not surface as "Invalid API key"
|
||||
/// on the first activation.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(ApiKeyOptions options) =>
|
||||
new ApiKeyOptionsValidator().Validate(Options.DefaultName, options);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Blank_pepper_fails(string pepper)
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = pepper });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain("Pepper is required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Short_pepper_fails()
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = "x" });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain($"at least {ApiKeyOptionsValidator.MinPepperLength}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_username_fails()
|
||||
{
|
||||
var result = Validate(new ApiKeyOptions { Pepper = new string('p', 32), Username = " " });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotBeNull();
|
||||
result.FailureMessage.ShouldContain("Username");
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the docker-dev rig's pepper and the defaults pass unchanged.</summary>
|
||||
[Fact]
|
||||
public void Configured_pepper_passes()
|
||||
{
|
||||
Validate(new ApiKeyOptions { Pepper = "docker-dev-apikey-pepper-not-for-production" })
|
||||
.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.5 — inside a Blazor circuit there is no <c>HttpContext</c> after the first render, so an
|
||||
/// HTTP-only accessor stamps every audit row from an interactive page <c>"system"</c>. The circuit's
|
||||
/// <see cref="AuthenticationStateProvider"/> is consulted first; the HTTP context is the fallback.
|
||||
/// </summary>
|
||||
public sealed class AuthenticationStateAuditActorAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Circuit_principal_wins_when_there_is_no_http_context()
|
||||
{
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(
|
||||
new HttpContextAccessorStub(null),
|
||||
new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice"))));
|
||||
|
||||
accessor.CurrentActor.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Http_principal_is_the_fallback_outside_a_circuit()
|
||||
{
|
||||
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) };
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null);
|
||||
|
||||
accessor.CurrentActor.ShouldBe("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unauthenticated_circuit_falls_through_to_http_then_null()
|
||||
{
|
||||
var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
|
||||
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit)
|
||||
.CurrentActor.ShouldBeNull();
|
||||
|
||||
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) };
|
||||
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit)
|
||||
.CurrentActor.ShouldBe("carol");
|
||||
}
|
||||
|
||||
/// <summary>An incomplete state task is "no circuit identity", never a block.</summary>
|
||||
[Fact]
|
||||
public void Pending_authentication_state_is_not_awaited()
|
||||
{
|
||||
var accessor = new AuthenticationStateAuditActorAccessor(
|
||||
new HttpContextAccessorStub(null),
|
||||
new PendingStateProvider());
|
||||
|
||||
accessor.CurrentActor.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
|
||||
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));
|
||||
|
||||
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; } = context;
|
||||
}
|
||||
|
||||
private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
|
||||
private sealed class PendingStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
||||
new TaskCompletionSource<AuthenticationState>().Task;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
@@ -37,6 +38,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
private IHost _host = null!;
|
||||
private TestServer _server = null!;
|
||||
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
|
||||
private readonly CapturingLoggerProvider _logs = new();
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -70,6 +72,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
|
||||
}).Build();
|
||||
services.AddOtOpcUaAuth(configuration);
|
||||
services.AddLogging(l => l.AddProvider(_logs));
|
||||
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
||||
// The login handler now resolves the DB role-map service via DI to merge
|
||||
// DB-backed grants on top of the appsettings baseline. Register the stub so
|
||||
@@ -96,6 +99,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
.ToArray();
|
||||
return Results.Ok(claims);
|
||||
}).RequireAuthorization();
|
||||
// A role-gated machine endpoint: proves a bearer principal's roles resolve through
|
||||
// IsInRole (the validation parameters pair RoleClaimType with the minted key).
|
||||
e.MapGet("/api/viewer-only", () => Results.Ok("viewer"))
|
||||
.RequireAuthorization(p => p.RequireRole("Viewer"));
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -153,6 +160,66 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
||||
// R03:2.7 — the outage is named in the server log, not inferred from the directory side.
|
||||
_logs.Entries.Any(e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("LDAP authentication failed") && e.Exception != null)
|
||||
.ShouldBeTrue("a Warning naming the LDAP failure must be logged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R03:2.4 — a token from <c>/auth/token</c> authenticates a request carrying it as a bearer:
|
||||
/// the fallback-protected root AND a role-gated endpoint (the bearer principal's "Role" claims
|
||||
/// resolve through <c>IsInRole</c>). No cookie is sent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Bearer_token_authenticates_api_requests_without_a_cookie()
|
||||
{
|
||||
var token = await LoginAndIssueTokenAsync();
|
||||
var client = NewClient();
|
||||
|
||||
var root = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var gated = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
|
||||
gated.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
var gatedResponse = await client.SendAsync(gated, Ct);
|
||||
gatedResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await gatedResponse.Content.ReadAsStringAsync(Ct)).ShouldContain("viewer");
|
||||
}
|
||||
|
||||
/// <summary>A tampered or garbage bearer fails closed (401), it never falls through to anonymous.</summary>
|
||||
[Theory]
|
||||
[InlineData("not-a-jwt")]
|
||||
[InlineData("tampered")]
|
||||
public async Task Bad_bearer_token_is_rejected(string kind)
|
||||
{
|
||||
var token = kind == "tampered"
|
||||
? (await LoginAndIssueTokenAsync()) + "x"
|
||||
: kind;
|
||||
var client = NewClient();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
|
||||
var response = await client.SendAsync(req, Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
|
||||
}
|
||||
|
||||
private async Task<string> LoginAndIssueTokenAsync()
|
||||
{
|
||||
var client = NewClient();
|
||||
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
AttachCookies(tokenReq, loginResponse);
|
||||
var response = await client.SendAsync(tokenReq, Ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
||||
return payload.GetProperty("token").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>Tests that ping anonymous returns 401.</summary>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>Collects every log entry written through the host's logger factory, for assertions on what was logged.</summary>
|
||||
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ConcurrentQueue<Entry> Entries { get; } = new();
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
internal sealed record Entry(string Category, LogLevel Level, string Message, Exception? Exception);
|
||||
|
||||
private sealed class Logger(string category, ConcurrentQueue<Entry> entries) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
entries.Enqueue(new Entry(category, logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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(JwtBearerAuthenticationHandler));
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user