Search code examples
node.jssignalr

SignalR connection fails after upgrading from NodeJS 17.9.1 to 18.0.0 when using https


I have a simple NodeJS app like the below
index.js

const signalR = require("@microsoft/signalr");

//Works if I disable TLS validation
//process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; 

let connection = new signalR.HubConnectionBuilder()
      .withUrl("https://myapp.mydomain.com:5000/messageHub",
     //.withUrl("http://myapp.mydomain.com:5002/messageHub", //works if I use http
    {
         withCredentials: false
    })
    .withAutomaticReconnect()
    .configureLogging(signalR.LogLevel.Information)
    .build();
    connection.logging = true;
    async function start() {
    try {
        await connection.start({ withCredentials: false });
        console.log("SignalR Connected.");
    } catch (err) {
        console.log(err);
        setTimeout(start, 5000);
    }
};

connection.onclose(async () => {
    await start();
});

start();

package.json

{
  "name": "Client",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@microsoft/signalr": "8.0.0"
  }
}

  • It works fine on Node 17.9.1 (or earlier), it fails on Node 18.0.0 (or later)
  • It works if I use http and not https
  • It works if I disable TLS validation but of course I don't want to do that process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;

My server uses SignalR 8 on .NET 8.

When I run the app node index.js I get

[2024-02-08T11:06:44.246Z] Error: Failed to complete negotiation with the server: TypeError: fetch failed
[2024-02-08T11:06:44.246Z] Error: Failed to start the connection: Error: Failed to complete negotiation with the server: TypeError: fetch failed
FailedToNegotiateWithServerError: Failed to complete negotiation with the server: TypeError: fetch failed
    at HttpConnection._getNegotiationResponse (C:\dev\20240227electron\node_modules\@microsoft\signalr\dist\cjs\HttpConnection.js:257:35)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async HttpConnection._startInternal (C:\dev\20240227electron\node_modules\@microsoft\signalr\dist\cjs\HttpConnection.js:170:41)
    at async HttpConnection.start (C:\dev\20240227electron\node_modules\@microsoft\signalr\dist\cjs\HttpConnection.js:73:9)
    at async HubConnection._startInternal (C:\dev\20240227electron\node_modules\@microsoft\signalr\dist\cjs\HubConnection.js:135:9)
    at async HubConnection._startWithStateTransitions (C:\dev\20240227electron\node_modules\@microsoft\signalr\dist\cjs\HubConnection.js:112:13)
    at async Timeout.start [as _onTimeout] (C:\dev\20240227electron\index.js:18:9) {

Any ideas?


Solution

  • This ended up being a certificate issue. Apparently, pre Node 18, there was a fallback option where if it initially failed to connected due to the issue mentioned above, it would fall back upon the SSE protocol, which does not look for certificate validation. Realization didn't occur back then that this was an issue because it worked by using the fallback option. As per Node 18 and above, there is now a need to import certificates from the windows store and then add them as trusted CAs via this code

    const ca = require('win-ca')
    
    var tls = require('tls');
    
    const rootCAs = [];
    
    export default function addExtraCA(){
    
    fetch().then(render)
    
    }
    
    function fetch() {
    
    var list = []
    
    return new Promise(resolve => {
    
    ca({
    
    async: true,
    
    format: ca.der2.txt,
    
    ondata: list,
    
    onend: resolve
    
    })
    
    })
    
    .then(_ => list)
    
    }
    
    function render(list) {
    
    for (let pem of list) {
    
    var crt= pem.substring(pem.indexOf('-----BEGIN CERTIFICATE-----'));
    
    var buf = Buffer.from(crt, 'utf8');
    
    console.log(buf);
    
    process.env.NODE_EXTRA_CA_CERTS = \${buf}``
    
    addDefaultCA(crt)
    
    }
    
    const origCreateSecureContext = tls.createSecureContext;
    
    tls.createSecureContext = function(options) {
    
    var c = origCreateSecureContext.apply(null, arguments);
    
    if (!options.ca && rootCAs.length > 0) {
    
    rootCAs.forEach(function(ca) {
    
    // add to the created context our own root CAs
    
    c.context.addCACert(ca);
    
    });
    
    }
    
    return c;
    
    };
    
    }
    
    function addDefaultCA(file) {
    
    try {
    
    var content = file
    
    content = content.replace(/\r\n/g, "\n"); // Handles certificates that have been created in Windows
    
    var regex = /-----BEGIN CERTIFICATE-----\n[\s\S]+?\n-----END CERTIFICATE-----/g;
    
    var results = content.match(regex);
    
    if (!results) throw new Error("Could not parse certificate");
    
    results.forEach(function(match) {
    
    var cert = match.trim();
    
    rootCAs.push(cert);
    
    });
    
    } catch (e) {
    
    if (e.code !== "ENOENT") {
    
    console.log("failed reading file " + file + ": " + e.message);
    
    }
    
    }
    
    }