Search code examples
node.jsnode-httpsmtls

Result of `req.socket.authorized` is not correct using nodejs https and express [mutual authentication]


I'm trying to set up a https server with mutual authentication.
I created the key and the certificate for the server (auto-signed).

Now I use firefox to connect to the server without providing any client certificate.
This should result in the req.socket.authorized being false (as stated here), but for some reason after some refreshes (and without changing anything) the message change from the right
Unauthorized: Client certificate required (UNABLE_TO_GET_ISSUER_CERT)
to
Client certificate was authenticated but certificate information could not be retrieved.

To me this is unexpected, because it means that req.socket.authorized == true even without client certificates. Can someone explain me why is this happening?


Here my code:

const express = require('express')
const app = express()
const fs = require('fs')
const https = require('https')

// ...

const opts = { key: fs.readFileSync('./cryptoMaterial/private_key.pem'),
               cert: fs.readFileSync('./cryptoMaterial/certificate.pem'),
               requestCert: true,
               rejectUnauthorized: false,
               ca: [ fs.readFileSync('./cryptoMaterial/certificate.pem') ]
             }

const clientAuthMiddleware = () => (req, res, next) => {
    if (!req.secure && req.header('x-forwarded-proto') != 'https') {
        return res.redirect('https://' + req.header('host') + req.url);
    }

    // Ensure that the certificate was validated at the protocol level
    if (!req.socket.authorized) { // <-- THIS SHOULD BE ALWAYS FALSE
        res.status(401).send(
            'Unauthorized: Client certificate required ' + 
                '(' + req.socket.authorizationError + ')'
        );
        return
    }

    // Obtain certificate details
    var cert = req.socket.getPeerCertificate();
    if (!cert || !Object.keys(cert).length) {
        // Handle the bizarre and probably not-real case that a certificate was
        // validated but we can't actually inspect it
        res.status(500).send(
            'Client certificate was authenticated but certificate ' +
                'information could not be retrieved.'
        );
        return
    }
    return next();
};
app.use(clientAuthMiddleware());

// ...

https.createServer(opts, app).listen(PORT)

Solution

  • I encountered the same issue a while ago and created an issue on github. It seems like it's intentional behaviour. See https://github.com/nodejs/node/issues/35317

    To quote bnoordhuis answering to "I'm guessing it might be due to some TLS connection reuse logic." in the issue:

    Close, it's not the connection but the TLS session that's reused. :-)

    Reuse cuts the handshake short (and cuts out the client certificate exchange) because it reuses the previously established session parameters. That's per spec and normally what you want. Chromium probably creates a new session when you reload.

    [...]

    socket.authorized is false when a verification error happened during the handshake (e.g. invalid or untrusted certificate) but true otherwise.

    A new connection started from a resumed session doesn't do that verification and hence assumes socket.authorized = true. The nature of TLS sessions is such that I'm not sure this can be fixed even if we wanted to.


    As a workaround, you should disable TLS renegotiation and force a new TLS session for each connection, which can be done only on TLSv1.2 as far as I know.

    Here's an example on how I'm achieving it with Typescript:

    import fs from 'fs';
    import path from 'path';
    import https from 'https';
    import tls from 'tls';
    import express from 'express';
    
    const expressApp = express();
    
    if (tls.DEFAULT_MAX_VERSION !== "TLSv1.2") {
        throw Error('Specify --tls-max-v1.2 as a node option (see https://github.com/nodejs/node/issues/35317)');
    }
    
    const httpsOptions = {
        key: fs.readFileSync(path.join('certs', 'key')),
        cert: fs.readFileSync(path.join('certs', 'cert')),
        ca: fs.readFileSync(path.join('certs', 'ca')),
        // crl: fs.readFileSync(path.join('certs', 'crl')), /* Enable this if you have a CRL */
        requestCert: true,
        rejectUnauthorized: false
    };
    
    https.createServer(httpsOptions, expressApp);
    
    /* Authentication middleware */
    expressApp.use((req, res, next) => {
        let tlsSocket = (req.socket as tls.TLSSocket);
        if (tlsSocket.isSessionReused()) {
            /* Force renegotiation (see https://github.com/nodejs/node/issues/35317) */
            tlsSocket.renegotiate({ rejectUnauthorized: false, requestCert: true }, (err) => {
                if (!(tlsSocket as tls.TLSSocket).authorized) {
                    console.log('Unauthorized');
                    return res.status(401).send('Unauthorized');
                }
            });
        }
        else {
            if (!(tlsSocket as tls.TLSSocket).authorized) {
                console.log('Unauthorized');
                return res.status(401).send('Unauthorized');
            }
        }
        next();
    });