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:
Marc Schier
2026-10-26 05:37:55 +01:00
committed by GitHub
parent f3980f9e72
commit 689358e2e3
@@ -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;