moscajs / aedes

Barebone MQTT broker that can run on any stream server, the node way
MIT License
1.81k stars 232 forks source link

[feat] Aedes-jwt authentication #147

Open martindederer opened 7 years ago

martindederer commented 7 years ago

Currently the username and password are not accessible from within the custom authorizePublish and authorizeSubscribe methods.

This becomes an issue when authenticating and authorizing with JWT or similar token based auth schemes is desired. In this case the decision to allow or deny a request to publish or subscribe to a certain MQTT topic depends on the payload of the auth token. The widespread solution for using auth tokens with the MQTT protocol seems to be to send the auth token in the username or password field on connection initialization.

Would you be interested in a PR that stores the username and password in the client instance on connection initialization? I would be happy to provide it in that case.

mcollina commented 7 years ago

@martindederer you can store them in the client object in authenticate. You can also add all the state you might need. I think it should be more than enough to validate topics during authorizePublish and authorizeSubscribe.

I prefer not storing the password in the clear in the default implementation.

If would be cool if you release an aedes-jwt integration, I think a lot of people would find it very useful.

martindederer commented 7 years ago

@mcollina I see, that would probably work. I am usually hesitant to modify structures like the client instance that third party code provides to the consumer code since it is a potential vector for future breakage. I will give it a try though.

I agree with your objection on plain text password storage in memory. It should be avoided if possible.

I'll have to clean up the aedes-jwt stuff quite a bit before i can release it for public consumption.

drasko commented 7 years ago

Any news on this issue? This is highly needed feature.

mcollina commented 7 years ago

Anybody that wants to send a PR?

scagood commented 3 years ago

:thinking: I dont think this should be part of the main library, as its acomplishable using the currently exposed auth methods. Maybe a Exensions/Plugin would be the best option to acomplish this.

In the mean time here is a really naive example:

Server using jsonwebtoken:

const net = require('net');
const Aedes = require('aedes');
const jwt = require('jsonwebtoken');

const server = net.createServer();
const aedes = new Aedes();

const secret = process.env.SECRET_OR_KEY;

aedes.authenticate = (client, username, password, callback) => {
  if (username === 'oauth2') {
    return jwt.verify(password.toString(), secret, (error, token) => {
      if (error) {
        return callback(error, false);
      }

      client.token = token;
      return callback(null, true);
    });
  }

  return callback(null, false);
};

function checkAnyScope(client, ...requiredScopes) {
  if (typeof client.token.scope !== 'string') {
    throw new TypeError('Token contains no scope');
  }

  const tokenScopes = client.token.scope.split(' ');

  for (const requiredScope of requiredScopes) {
    if (tokenScopes.includes(requiredScope)) {
      return;
    }
  }

  throw new Error('Insufficient to permissions to publish message');
}

aedes.authorizePublish = (client, packet, callback) => {
  if (client.token instanceof Object) {
    try {
      checkAnyScope(client, 'aedes-write');

      return callback(null);
    } catch (error) {
      return callback(error);
    }
  }

  callback(new Error('Cannot publish'));
};

aedes.authorizeSubscribe = (client, subscription, callback) => {
  if (client.token instanceof Object) {
    try {
      checkAnyScope(client, 'aedes-read');

      return callback(null, subscription);
    } catch (error) {
      return callback(error);
    }
  }

  callback(new Error('Cannot subscribe'));
};

server.on('connection', aedes.handle);
server.on('error', console.error);

aedes.on('clientError', (client, error) => console.error(error));
aedes.on('connectionError', (client, error) => console.error(error));

server.listen(1883);

Client using MQTT.js:

const mqtt = require('mqtt');

const client = mqtt.connect({
  clientId: 'some-client',
  username: 'oauth2',

  // This token was generated using https://jwt.io/
  // The secret is: `something-secret`
  // It decodes to:
  // { "sub": "someone", "scope": "aedea-write aedes-read" }
  password: [
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
    'eyJzdWIiOiJzb21lb25lIiwic2NvcGUiOiJhZWRlYS13cml0ZSBhZWRlcy1yZWFkIn0',
    'B5pNFLaZuNz9cQueABiSAaxoHlmtOygw8jaWHnR1nyo',
  ].join('.'),
});

client.on('connect', () => console.info('connect'));
client.on('disconnect', () => console.info('disconnect'));
client.on('error', console.error);

client.subscribe('topic-name', { qos: 2 });
client.on('message', (topic, message) => {
  console.log(message.toString());
});
robertsLando commented 3 years ago

@scagood The main idea was about creating a dedicated extension/plugin for this like aedes-jwt. If you would like to implement that just let me know, I can add you to the org and create the repo :)

BTW thanks for the example

btsimonh commented 9 months ago

I've just ported my jwt authentication from mosca to aedes. I did basically as described above - stored my decoded token in the client structure as 'token', and then use this to qualify any subscribes or publishes. My token contains data.write, data.read, and data.rw as arrays of topic prefixes, and in the auth functions, I just compare the start of the subscription/publish with the relevant fields. The other useful feature is that because I rotate my secrets regularly, I look for a publish to 'newtoken' (in authorizepublish), and when found, update the stored token data without the need to disconnect/reconnect. I also capture the IP address of the client at authentication (from either an MQTT server or a WS: server). This is also stored in the client.

So, the question is, how to avoid conflicts with other properties of 'client'. It may be useful to specify that a key (e.g. 'client.userdata') will NEVER be used by aedes, and so is free for use by people using aedes? Either that, or to specify a prefix which will never be used... (e.g. any key which starts 'u_'). This would give use the confidence to modify the structure without concern.

e.g. I would use

client.userdata = {
  token: <clienttoken>
  ip: <captured ip>
  moreOfMyShit:<something I only care about>
}

br,

Simon

robertsLando commented 9 months ago

It may be useful to specify that a key (e.g. 'client.userdata') will NEVER be used by aedes, and so is free for use by people using aedes?

Would you like to submit a PR for that?

btsimonh commented 9 months ago

I don't see a need to modify the operational code for this - so I'll have a look at adding to the docs to suggest this key will never be used by the code, and so is free for use. e.g. in the client doc, readme and examples doc.