feat: websocket improve to fix issue #7268 including support authentication (#7304)

Co-authored-by: sofia.fernandez <sofia.fernandez@six-group.com>
This commit is contained in:
sofia-fernandez-six 2026-04-21 13:15:06 +02:00 committed by GitHub
commit 2f45b46315
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 314 additions and 25 deletions

View file

@ -1,7 +1,7 @@
const { MonitorType } = require("./monitor-type");
const WebSocket = require("ws");
const { UP } = require("../../src/util");
const { checkStatusCode } = require("../util-server");
const { checkStatusCode, getOidcTokenClientCredentials } = require("../util-server");
// Define closing error codes https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
const WS_ERR_CODE = {
1002: "Protocol error",
@ -50,17 +50,77 @@ class WebSocketMonitorType extends MonitorType {
throw new Error("Unknown Websocket Error");
}
/**
* Builds the WebSocket options object for authentication and TLS.
* Supports basic auth, OAuth2 client credentials, and mTLS.
* @param {object} monitor The monitor object for input parameters.
* @returns {Promise<object>} The options object to pass to the WebSocket constructor.
*/
async buildWsOptions(monitor) {
const options = {};
const timeoutMs = (monitor.timeout ?? 20) * 1000;
options.handshakeTimeout = timeoutMs;
// Parse custom headers if provided
if (monitor.headers) {
try {
options.headers = JSON.parse(monitor.headers);
} catch (e) {
// If headers is not valid JSON, ignore it
options.headers = {};
}
} else {
options.headers = {};
}
if (monitor.authMethod === "basic") {
if (monitor.basic_auth_user || monitor.basic_auth_pass) {
const credentials = Buffer.from(
`${monitor.basic_auth_user ?? ""}:${monitor.basic_auth_pass ?? ""}`
).toString("base64");
options.headers.Authorization = `Basic ${credentials}`;
}
} else if (monitor.authMethod === "oauth2-cc") {
if (new Date((monitor.oauthAccessToken?.expires_at || 0) * 1000) <= new Date()) {
monitor.oauthAccessToken = await getOidcTokenClientCredentials(
monitor.oauth_token_url,
monitor.oauth_client_id,
monitor.oauth_client_secret,
monitor.oauth_scopes,
monitor.oauth_audience,
monitor.oauth_auth_method
);
}
options.headers.Authorization = `${monitor.oauthAccessToken.token_type} ${monitor.oauthAccessToken.access_token}`;
} else if (monitor.authMethod === "mtls") {
if (monitor.tlsCert) {
options.cert = monitor.tlsCert;
}
if (monitor.tlsKey) {
options.key = monitor.tlsKey;
}
if (monitor.tlsCa) {
options.ca = monitor.tlsCa;
}
options.rejectUnauthorized = !monitor.getIgnoreTls();
}
return options;
}
/**
* Uses the ws Node.js library to establish a connection to target server
* @param {object} monitor The monitor object for input parameters.
* @returns {Promise<[ string, int ]>} Array containing a status message and response code
*/
async attemptUpgrade(monitor) {
const authOptions = await this.buildWsOptions(monitor);
return new Promise((resolve) => {
const timeoutMs = (monitor.timeout ?? 20) * 1000;
// If user inputs subprotocol(s), convert to array, set Sec-WebSocket-Protocol header, timeout in ms. Subprotocol Identifier column: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name
const subprotocol = monitor.wsSubprotocol ? monitor.wsSubprotocol.replace(/\s/g, "").split(",") : undefined;
const ws = new WebSocket(monitor.url, subprotocol, { handshakeTimeout: timeoutMs });
const ws = new WebSocket(monitor.url, subprotocol, authOptions);
ws.addEventListener("open", (event) => {
// Immediately close the connection

View file

@ -976,6 +976,7 @@
"Analytics ID": "Analytics ID",
"Analytics Script URL": "Analytics Script URL",
"Edit Tag": "Edit Tag",
"WebSocket Options": "WebSocket Options",
"Server Address": "Server Address",
"Learn More": "Learn More",
"Body Encoding": "Body Encoding",

View file

@ -202,28 +202,43 @@
/>
</div>
<!-- Websocket Subprotocol Docs: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name -->
<div v-if="monitor.type === 'websocket-upgrade'" class="my-3">
<label for="ws_subprotocol" class="form-label">{{ $t("Subprotocol(s)") }}</label>
<input
id="ws_subprotocol"
v-model="monitor.wsSubprotocol"
type="text"
class="form-control"
placeholder="mielecloudconnect,soap"
/>
<i18n-t tag="div" class="form-text" keypath="wsSubprotocolDescription">
<template #documentation>
<a
href="https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name"
target="_blank"
rel="noopener noreferrer"
>
{{ $t("documentationOf", ["IANA"]) }}
</a>
</template>
</i18n-t>
</div>
<template v-if="monitor.type === 'websocket-upgrade'">
<h2 class="mt-5 mb-2">{{ $t("WebSocket Options") }}</h2>
<!-- Websocket Subprotocol Docs: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name -->
<div class="my-3">
<label for="ws_subprotocol" class="form-label">{{ $t("Subprotocol(s)") }}</label>
<input
id="ws_subprotocol"
v-model="monitor.wsSubprotocol"
type="text"
class="form-control"
placeholder="mielecloudconnect,soap"
/>
<i18n-t tag="div" class="form-text" keypath="wsSubprotocolDescription">
<template #documentation>
<a
href="https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name"
target="_blank"
rel="noopener noreferrer"
>
{{ $t("documentationOf", ["IANA"]) }}
</a>
</template>
</i18n-t>
</div>
<!-- Custom Headers -->
<div class="my-3">
<label for="ws-headers" class="form-label">{{ $t("Headers") }}</label>
<textarea
id="ws-headers"
v-model="monitor.headers"
class="form-control"
:placeholder="headersPlaceholder"
></textarea>
</div>
</template>
<!-- gRPC URL -->
<div v-if="monitor.type === 'grpc-keyword'" class="my-3">
@ -2058,6 +2073,176 @@
{{ $t("Setup Notification") }}
</button>
<!-- WebSocket Authentication -->
<template v-if="monitor.type === 'websocket-upgrade'">
<h2 class="mt-5 mb-2">{{ $t("Authentication") }}</h2>
<!-- Auth Method -->
<div class="my-3">
<label for="ws-auth-method" class="form-label">{{ $t("Method") }}</label>
<select id="ws-auth-method" v-model="monitor.authMethod" class="form-select">
<option :value="null">
{{ $t("None") }}
</option>
<option value="basic">
{{ $t("HTTP Basic Auth") }}
</option>
<option value="oauth2-cc">
{{ $t("OAuth2: Client Credentials") }}
</option>
<option value="mtls">mTLS</option>
</select>
</div>
<template v-if="monitor.authMethod === 'basic'">
<div class="my-3">
<label for="ws-basicauth-user" class="form-label">{{ $t("Username") }}</label>
<input
id="ws-basicauth-user"
v-model="monitor.basic_auth_user"
type="text"
class="form-control"
:placeholder="$t('Username')"
/>
</div>
<div class="my-3">
<label for="ws-basicauth-pass" class="form-label">{{ $t("Password") }}</label>
<HiddenInput
id="ws-basicauth-pass"
v-model="monitor.basic_auth_pass"
autocomplete="new-password"
:placeholder="$t('Password')"
/>
</div>
</template>
<template v-else-if="monitor.authMethod === 'oauth2-cc'">
<div class="my-3">
<label for="ws-oauth-auth-method" class="form-label">
{{ $t("Authentication Method") }}
</label>
<select
id="ws-oauth-auth-method"
v-model="monitor.oauth_auth_method"
class="form-select"
>
<option value="client_secret_basic">
{{ $t("Authorization Header") }}
</option>
<option value="client_secret_post">
{{ $t("Form Data Body") }}
</option>
</select>
</div>
<div class="my-3">
<label for="ws-oauth-token-url" class="form-label">
{{ $t("OAuth Token URL") }}
</label>
<input
id="ws-oauth-token-url"
v-model="monitor.oauth_token_url"
type="text"
class="form-control"
:placeholder="$t('OAuth Token URL')"
required
/>
</div>
<div class="my-3">
<label for="ws-oauth-client-id" class="form-label">
{{ $t("Client ID") }}
</label>
<input
id="ws-oauth-client-id"
v-model="monitor.oauth_client_id"
type="text"
class="form-control"
:placeholder="$t('Client ID')"
required
/>
</div>
<template
v-if="
monitor.oauth_auth_method === 'client_secret_post' ||
monitor.oauth_auth_method === 'client_secret_basic'
"
>
<div class="my-3">
<label for="ws-oauth-client-secret" class="form-label">
{{ $t("Client Secret") }}
</label>
<HiddenInput
id="ws-oauth-client-secret"
v-model="monitor.oauth_client_secret"
:placeholder="$t('Client Secret')"
:required="true"
/>
</div>
<div class="my-3">
<label for="ws-oauth-scopes" class="form-label">
{{ $t("OAuth Scope") }}
</label>
<input
id="ws-oauth-scopes"
v-model="monitor.oauth_scopes"
type="text"
class="form-control"
:placeholder="$t('Optional: Space separated list of scopes')"
/>
</div>
<div class="my-3">
<label for="ws-oauth-audience" class="form-label">
{{ $t("OAuth Audience") }}
</label>
<input
id="ws-oauth-audience"
v-model="monitor.oauth_audience"
type="text"
class="form-control"
:placeholder="$t('Optional: The audience to request the JWT for')"
/>
</div>
</template>
</template>
<template v-else-if="monitor.authMethod === 'mtls'">
<div class="my-3">
<label for="ws-tls-cert" class="form-label">
{{ $t("mtls-auth-server-cert-label") }}
</label>
<textarea
id="ws-tls-cert"
v-model="monitor.tlsCert"
class="form-control"
:placeholder="$t('mtls-auth-server-cert-placeholder')"
required
></textarea>
</div>
<div class="my-3">
<label for="ws-tls-key" class="form-label">
{{ $t("mtls-auth-server-key-label") }}
</label>
<textarea
id="ws-tls-key"
v-model="monitor.tlsKey"
class="form-control"
:placeholder="$t('mtls-auth-server-key-placeholder')"
required
></textarea>
</div>
<div class="my-3">
<label for="ws-tls-ca" class="form-label">
{{ $t("mtls-auth-server-ca-label") }}
</label>
<textarea
id="ws-tls-ca"
v-model="monitor.tlsCa"
class="form-control"
:placeholder="$t('mtls-auth-server-ca-placeholder')"
></textarea>
</div>
</template>
</template>
<!-- Proxies -->
<div
v-if="

View file

@ -380,4 +380,47 @@ describe("WebSocket Monitor", {}, () => {
await websocketMonitor.check(monitor, heartbeat, {});
assert.deepStrictEqual(heartbeat, expected);
});
test("buildWsOptions() includes custom headers", async () => {
const websocketMonitor = new WebSocketMonitorType();
const options = await websocketMonitor.buildWsOptions({
headers: JSON.stringify({
"X-Test": "test-value",
}),
});
assert.deepStrictEqual(options.headers, {
"X-Test": "test-value",
});
});
test("buildWsOptions() ignores invalid custom headers JSON", async () => {
const websocketMonitor = new WebSocketMonitorType();
const options = await websocketMonitor.buildWsOptions({
headers: "{ invalid-json",
});
assert.deepStrictEqual(options.headers, {});
});
test("buildWsOptions() authentication header overrides custom Authorization header", async () => {
const websocketMonitor = new WebSocketMonitorType();
const options = await websocketMonitor.buildWsOptions({
headers: JSON.stringify({
Authorization: "Bearer custom-token",
"X-Test": "test-value",
}),
authMethod: "basic",
basic_auth_user: "user",
basic_auth_pass: "pass",
});
assert.deepStrictEqual(options.headers, {
Authorization: "Basic dXNlcjpwYXNz",
"X-Test": "test-value",
});
});
});