mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 15:11:53 +08:00
WebApi tests: verify opcua+openapi WebSocket upgrade credential sources (#4893)
# Description Test-only change (no production code changes). Adds regression coverage for WebSocket upgrade authentication introduced in #4699, using real Kestrel/TLS listeners in `RealHttpsListenerIntegrationTests.Authentication.cs`: - A `WebSocketUpgradeRecorder` startup contributor captures, **before** authentication runs, whether the upgrade request carried an `Authorization` header or a client certificate. This proves the credential source, since the server internally copies a subprotocol token into the `Authorization` header for the JwtBearer handler. - `opcua+openapi+<accesstoken>` authenticates **without** any `Authorization` header or client certificate (HTTP 101, subprotocol selected, upstream identity propagated). This is the browser-compatible path. - Plain `opcua+openapi` upgrades without credentials are rejected with 401 when Basic or Bearer REST auth is configured, and succeed (101) when a valid header is supplied. - With mTLS, a missing client certificate is rejected and a valid certificate is accepted without any header. - New test `TokenOpenApiWebSocketUpgradeWithoutAuthorizationHeaderRejectsUnvalidatedTokenAsync`: bogus or forged bearer tokens, and subprotocol tokens when the Bearer scheme is not registered (Basic and mTLS configurations), are rejected with 401 and never dispatched. - The expired-token test now asserts the credential source for both the header and subprotocol variants. ## Related Issues - Follow-up to #4699 / #4674 ## Validation - `dotnet test tests\Opc.Ua.Bindings.Https.WebApi.Tests -f net10.0`: **520 passed**, 0 failed - `dotnet test tests\Opc.Ua.Bindings.Https.WebApi.Tests -f net8.0 --filter FullyQualifiedName~RealHttpsListenerIntegrationTests`: **126 passed**, 0 failed - Not run: the full UA.slnx suite. net48 does not apply because this binding targets net8.0+ only. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received.
This commit is contained in:
+79
-4
@@ -314,6 +314,10 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
.ConnectWebSocketAsync(Profiles.OpcUaWsSubProtocolOpenApi, CreateAuthorizationHeader(credential))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(listener.Upgrade.HasAuthorizationHeader,
|
||||
Is.EqualTo(credential is not ("none" or "client-cert")));
|
||||
Assert.That(listener.Upgrade.HasClientCertificate, Is.EqualTo(credential == "client-cert"));
|
||||
|
||||
if (!expectOpen)
|
||||
{
|
||||
Assert.That(socket.State, Is.Not.EqualTo(WebSocketState.Open));
|
||||
@@ -336,6 +340,7 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(socket.HttpStatusCode, Is.EqualTo(HttpStatusCode.SwitchingProtocols));
|
||||
Assert.That(socket.State, Is.EqualTo(WebSocketState.Open));
|
||||
Assert.That(socket.SubProtocol, Is.EqualTo(Profiles.OpcUaWsSubProtocolOpenApi));
|
||||
IServiceResponse response = await listener
|
||||
@@ -359,7 +364,7 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TokenOpenApiWebSocketUpgradeCarriesUpstreamIdentityAsync()
|
||||
public async Task TokenOpenApiWebSocketUpgradeWithoutAuthorizationHeaderCarriesUpstreamIdentityAsync()
|
||||
{
|
||||
var identities = new UpstreamIdentityProvider();
|
||||
await using AuthListener listener = await OpenAuthListenerAsync(
|
||||
@@ -367,12 +372,17 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
configureServices: services => services.AddSingleton<ISessionlessIdentityProvider>(identities))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string subProtocol = Profiles.OpcUaWsSubProtocolOpenApiBearerPrefix + CreateJwt(s_jwtSigningKey);
|
||||
using ClientWebSocket socket = await listener
|
||||
.ConnectWebSocketAsync(
|
||||
Profiles.OpcUaWsSubProtocolOpenApiBearerPrefix + CreateJwt(s_jwtSigningKey),
|
||||
subProtocol,
|
||||
authorization: null)
|
||||
.ConfigureAwait(false);
|
||||
Assert.That(listener.Upgrade.HasAuthorizationHeader, Is.False);
|
||||
Assert.That(listener.Upgrade.HasClientCertificate, Is.False);
|
||||
Assert.That(socket.HttpStatusCode, Is.EqualTo(HttpStatusCode.SwitchingProtocols));
|
||||
Assert.That(socket.State, Is.EqualTo(WebSocketState.Open));
|
||||
Assert.That(socket.SubProtocol, Is.EqualTo(subProtocol));
|
||||
IServiceResponse response = await listener
|
||||
.SendWebSocketRequestAsync(
|
||||
socket,
|
||||
@@ -389,6 +399,30 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
Is.SameAs(identities.LastIdentity));
|
||||
}
|
||||
|
||||
[TestCase("bearer", "bearer-bogus")]
|
||||
[TestCase("bearer", "bearer-forged")]
|
||||
[TestCase("basic", "bearer-valid")]
|
||||
[TestCase("mtls", "bearer-valid")]
|
||||
public async Task TokenOpenApiWebSocketUpgradeWithoutAuthorizationHeaderRejectsUnvalidatedTokenAsync(
|
||||
string authMode,
|
||||
string credential)
|
||||
{
|
||||
await using AuthListener listener = await OpenAuthListenerAsync(authMode).ConfigureAwait(false);
|
||||
string token = CreateAuthorizationHeader(credential)!.Parameter!;
|
||||
|
||||
using ClientWebSocket socket = await listener
|
||||
.ConnectWebSocketAsync(
|
||||
Profiles.OpcUaWsSubProtocolOpenApiBearerPrefix + token,
|
||||
authorization: null)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(listener.Upgrade.HasAuthorizationHeader, Is.False);
|
||||
Assert.That(listener.Upgrade.HasClientCertificate, Is.False);
|
||||
Assert.That(socket.State, Is.Not.EqualTo(WebSocketState.Open));
|
||||
Assert.That(socket.HttpStatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
Assert.That(listener.Callback.LastRequest, Is.Null);
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public async Task OpenApiWebSocketUpgradeWithExpiredTokenIsRejectedAsync(bool tokenInSubProtocol)
|
||||
@@ -409,6 +443,7 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
|
||||
Assert.That(socket.State, Is.Not.EqualTo(WebSocketState.Open));
|
||||
Assert.That(socket.HttpStatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
Assert.That(listener.Upgrade.HasAuthorizationHeader, Is.EqualTo(!tokenInSubProtocol));
|
||||
Assert.That(listener.Callback.LastRequest, Is.Null);
|
||||
}
|
||||
|
||||
@@ -699,6 +734,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
new ServiceProviderOptions { ValidateScopes = true });
|
||||
var callback = new StubTransportListenerCallback();
|
||||
var factory = new HttpsTransportListenerFactory();
|
||||
var upgrade = new WebSocketUpgradeRecorder();
|
||||
factory.StartupContributors.Add(upgrade);
|
||||
factory.StartupContributors.Add(provider.GetRequiredService<WebApiHttpsStartupContributor>());
|
||||
HttpsTransportListener listener;
|
||||
int port;
|
||||
@@ -730,7 +767,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
provider,
|
||||
provider.GetRequiredService<WebApiServer>(),
|
||||
listener,
|
||||
callback);
|
||||
callback,
|
||||
upgrade);
|
||||
try
|
||||
{
|
||||
await WaitForListenerReadyAsync(port).ConfigureAwait(false);
|
||||
@@ -854,6 +892,36 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
}
|
||||
#pragma warning restore CA1812
|
||||
|
||||
/// <summary>
|
||||
/// Records incoming upgrade credentials before authentication can modify the headers.
|
||||
/// </summary>
|
||||
private sealed class WebSocketUpgradeRecorder : IHttpsListenerStartupContributor
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the client supplied an Authorization header on the last upgrade.
|
||||
/// </summary>
|
||||
public bool? HasAuthorizationHeader { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the client supplied a TLS certificate on the last upgrade.
|
||||
/// </summary>
|
||||
public bool? HasClientCertificate { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(IApplicationBuilder appBuilder, HttpsTransportListener listener)
|
||||
{
|
||||
appBuilder.Use(async (context, next) =>
|
||||
{
|
||||
if (context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
HasAuthorizationHeader = context.Request.Headers.ContainsKey("Authorization");
|
||||
HasClientCertificate = context.Connection.ClientCertificate != null;
|
||||
}
|
||||
await next(context).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A listener whose REST authentication comes from an application
|
||||
/// container configured the way an operator would.
|
||||
@@ -871,18 +939,25 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
|
||||
ServiceProvider provider,
|
||||
WebApiServer server,
|
||||
HttpsTransportListener listener,
|
||||
StubTransportListenerCallback callback)
|
||||
StubTransportListenerCallback callback,
|
||||
WebSocketUpgradeRecorder upgrade)
|
||||
{
|
||||
m_provider = provider;
|
||||
m_server = server;
|
||||
m_listener = listener;
|
||||
Callback = callback;
|
||||
Upgrade = upgrade;
|
||||
}
|
||||
|
||||
public StubTransportListenerCallback Callback { get; }
|
||||
|
||||
public HttpsTransportListener Listener => m_listener;
|
||||
|
||||
/// <summary>
|
||||
/// The incoming upgrade credentials observed before authentication.
|
||||
/// </summary>
|
||||
public WebSocketUpgradeRecorder Upgrade { get; }
|
||||
|
||||
public void Connect(int port, X509Certificate2? clientCertificate)
|
||||
{
|
||||
m_port = port;
|
||||
|
||||
Reference in New Issue
Block a user