This page is to illustrate a simple and practical use of Json Web Tokens in authorisation of MQTT; in our case using Mosca as the MQTT server, and a modified Node Red as the primary MQTT client.

Mosca is an MQTT server implemented in Node.js by Matteo Collina.  One of it’s attractions is that enhancing it in terms of authorising users and authorising access to MQTT endpoints is only a matter of providing a couple of javascript functions.  This seems far easier than writing a plugin for Mosquito.  (and anyway, Mosca can use Mosquito as a backend?).

One of the things we wanted to achieve in Nebula was to use MQTT as a communication mechanism, but without a heavy user database accessed by all the microservices involved.  One way to achieve this is to use a JWT to authorise access, by specifying the access within the JWT data.

What is a Jason Web Token? – a JWT is a small chunk of data which encodes a piece of JSON.  The data is not encrypted (i.e. it can be read by anyone), but is signed.  The signature allows the validity of the token to be established by software which knows the secret key which it was signed with.  You can sign them with a public/private key arrangement, so allowing them to be validated but not created by the recipient if the recipient knows the public key.  A JWT can also be marked with creation and expiry dates, so time-limiting the validity of the data.

We chose to both create and validate our JWTs on the server, so no secret needs to be distributed.

Because a JWT can contain (a little) user data, we can encode into a JWT the MQTT endpoints which would be accessible using that JWT, and then read this data in Mosca – so Mosca does not need to know anything about users, roles or anything else apart from the required secret to verify the JWT.  The Mosca (and other microservice code) becomes stateless with regard to users and access rights…

Because the JWTs we use are marked as short lived (anything from 30 seconds to 30 minutes lifetime), even if a JWT leaks out, the risk is limited as the JWT becomes unusable after a short period.  However, this does mean that in the case of MQTT, the credentials required to access the Mosca instance are somewhat dynamic.

If you’ve read other pages from this site, you’ll know that I’m a big fan of Node Red; but the std Node-Red MQTT client has two issues.

  1. it does not do WSS://
  2. the credentials are static.

To overcome this, the MQTT client has been modified to accept dynamic credential changes (via an input message which changes the credentials on an MQTT config node), and to allow the MQTT server to be specified as a full URL.  (note: this does mean we need a separate MQTT config node for each different type of access; to facilitate visibility of this, a recent change to the MQTT node to allow a name to be included on the node has just got into the NR trunk, and note the already present ability to specify that a configuration node is part of a flow, not global.  Generally we allocate access rights to one subscribe MQTT endpoint and one publish MQTT endpoint, although we allow for an array of each of the access types read, write, and rw).

 

Some examples:

So, in Nebula, we have a concept of an ‘engine’; without going into details of Nebula, an engine needs to be able to receive its commands, and publish its status.  So when creating an engine, we create a JWT which allows access to two MQTT endpoints:

Write only access (publish) to nebula/engine/<engineID>/stat

Read/Write access to (publish and subscribe) to nebula/engine/<engineID>/cmd

This is represented within a ‘data’ json structure within the JWT, and the JWT signed with the current secret, and then the JWT passed to the engine at startup.  The JWT has a lifetime of 10 minutes, before which it must be refreshed.

var engineops_tokendata = {
 data:{
 name: 'engineops',
 id:msg.payload.id,
 write:['nebula/engine/'+msg.payload.id+'/stat'],
 rw:['nebula/engine/'+msg.payload.id+'/cmd']
 }
};

// allow 10 minutes between refreshes
var engineops_token = jwt.sign(engineops_tokendata, thissecret, {expiresIn: 10*60});

The JWT is refreshed through a microservice (written in Node Red!) which allows any valid JWT to be extended by its original validity time, without changing the access rights.

The JWT is sent as the password to Mosca with a well-known username in order to authorise MQTT access.

At Mosca, there are two functions which deal with authorisation:

authenticate, which deals with authorising a login; here, if the user matches our expected username, Mosca reads the current secrets (current and last secret, as the secrets rotate), and then checks the JWT validity.  If the JWT is valid, then the decoded token is stored against the Mosca ‘Client’ (as this contains the publish/subscribe points), and the login is allowed.  Note how it checks the validity against a current and previous secret; secret rotation is important, but this does mean the secrets must be rotate less frequently than the longest JWT expiry time.

// Accepts the connection if the username and password are valid
var authenticate = function(client, username, password, callback) {  
  var authorized = false;
  var secrets = currentsecrets();

  if (username === 'knownjwtuser'){
    try {      
      if (!util.isString(password)){         
        password = '' + password.toString();      
      }      
      // note: throws if invalid.
      var token = jwt.verify( password, secrets[0] );
      // store token away for future use
      client.token = token;
      //console.log("token "+ util.inspect(token));
      authorized = true;    
    } catch (e){
      try {
        var token = jwt.verify( password, secrets[1]);
        // store token away for future use
        client.token = token;
        console.log("used lastsecret");
        authorized = true;
      } catch (e){
        // neither secret worked, not authorized console.log("authorize user " + username + " pwd " + ((password)?password.toString():'undefined') + " client " + client.id);
        console.log("jwt user failed authorization because " + util.inspect(e)); try { console.log("jwt decoded at " + (((new Date()).valueOf()/1000)>>0) + " is " + util.inspect(jwt.decode(password))); } catch (e){ console.log("jwt not decodable"); } console.log("allow login, but disallow access"); authorized = true; client.token = {};
      }    
    }  
  } else {
  }
  if (authorized) client.user = username;
  callback(null, authorized);
}

 

and authorizePublish/authorizeSubscribe, which check authority for publish/subscribe points.  These check for a simple match of the start of the publish/subscribe point requested against the allowed endpoints and access types specified decoded JWT token data.

// very simple grant matching for the moment, the grant MUST be the start of the topic
var grantvalid = function(topic, grant){
 if (topic.startsWith(grant)){
  return true;
 }
 return false;
}

// In this case we use data from the client token which contains the topics they
// may publish to
var authorizePublish = function(client, topic, payload, callback) {
 var auth = 'ignore';//true;
 if (client.token){
   if (client.token.data){
     if (client.token.data.write){
       client.token.data.write.forEach(function(grant){
         if (grantvalid(topic, grant)){
         auth = true;
         }
       });
     }
     if (client.token.data.rw){
       client.token.data.rw.forEach(function(grant){
         if (grantvalid(topic, grant)){
         auth = true;
         }
       });
     }
   }
 }

 if (auth !== true)
   console.log("auth "+auth+" for Publish topic " + topic + " client "+client.id+" allowed " + util.inspect(client.token.data));
 callback(null, auth);
}

// In this case we use data from the client token which contains the topics they
// may subscribe to
var authorizeSubscribe = function(client, topic, callback) {
 var auth = false;
 if (client.token){
   if (client.token.data){
     if (client.token.data.read){
       client.token.data.read.forEach(function(grant){
         if (grantvalid(topic, grant)){
           auth = true;
         }
       });
     }
     if (client.token.data.rw){
       client.token.data.rw.forEach(function(grant){
         if (grantvalid(topic, grant)){
           auth = true;
         }
       });
     }
   }
 }

 if (auth !== true)
   console.log("auth "+auth+" for Subscribe topic " + topic + " client "+client.id+" allowed " + util.inspect(client.token.data));
 callback(null, auth);
}

Note that the very simple grant code above does not support wildcards in the MQTT path; or at least not to further extent that allowing ‘anything starting with’.  But it illustrates how easy it is to perform custom authorisation in Mosca.

 

Conclusion:

With less than 100 lines of fairly simple code, we have a method of providing secure access to many different MQTT paths to a distributed processing system, where the credentials issued are short-lived, and where user database access is limited to a single location, and ‘credentials’ boil down to two ‘secrets’, access to which can be easily understood and controlled.  Simplicity is often the key to successful security….

Â