DEV Community

Cover image for AWS IoT Core Simplified - Part 2: Presigned URL
Robert Slootjes
Robert Slootjes

Posted on

AWS IoT Core Simplified - Part 2: Presigned URL

This is part 2 in a series of articles about IoT Core:

Parts coming up:
Part 3: Connect using a custom authorizer
Part 4: Topic Rules

Connect using a presigned url

Similar to S3, it's possible to create a presigned url for IoT Core. You can even cache and reuse this presigned url for multiple clients as long as the client id is different per client. If you connect a client with a client id that is already in use, the other client will be disconnected. This can be a great method for pushing content to clients but also allows them to publish things themselves if your use case requires it. While this is a super easy method, it isn't the most flexible way of using it in terms of permissions. Please note that this only works for websocket urls, not for regular MQTT connections.

Permissions

It's important to realize that the presigned url has the same permissions as the role that was used to sign it with. That means that all clients will have the exact same permissions unless you specifically create and use a different role per use case. For example, if your Lambda has a role that allows connecting with any client ID on any topic, every client will be able to connect to any topic. In some cases this is totally fine but make sure this is OK for your use case. Obviously, you can (and you should) restrict your Lambda to only allow what you want your clients to do.

For instance, if you have a website where you want to be able to push live news updates to an end user, you can have a policy with this in it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iot:Connect",
        "iot:Subscribe",
        "iot:Receive"
      ],
      "Resource": [
        "arn:aws:iot:{region}:{account-id}:client/user-*",
        "arn:aws:iot:{region}:{account-id}:topicfilter/news",
        "arn:aws:iot:{region}:{account-id}:topic/news"
        ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This will allow the client to connect with any client ID as long as it starts with user- (you could generate a uuid to make it random), and will allow to subscribe to the "news" topic and receive messages over it. This is now basically a read only connect as the client isn't allowed to publish any messages with this policy.

You can have a separate role for your publishers that allows to write updates to the topic:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iot:Connect",
        "iot:Publish"
      ],
      "Resource": [
        "arn:aws:iot:{region}:{account-id}:client/publisher-*",
        "arn:aws:iot:{region}:{account-id}:topic/news"
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can of also publish a message using the AWS SDK instead of doing this with a client that is connected with MQTT. The iot:Publish permission to the respective topic is still required obviously.

Computer code and envelopes

Code

This seems to be quite hidden in the documentation however I did find some example code. I converted this into something that makes use of the current AWS utilities which makes it a lot more compact and readable. The code examples are written in Typescript, I recommend using Serverless Framework with esbuild to deploy the code so you can run it with a node runtime in Lambda.

import * as crypto from 'node:crypto';
import { type BinaryLike } from 'node:crypto';
import { Sha256 } from '@aws-crypto/sha256-js';
import { type AwsCredentialIdentity } from '@aws-sdk/types';
import { SignatureV4 } from '@smithy/signature-v4';

const sha256 = (data: BinaryLike): string =>
  crypto.createHash('sha256').update(data).digest().toString('hex');

const toQueryString = (queryStrings: Record<string, string>): string => Object.entries(queryStrings)
  .map(([key, value]) => `${key}=${value}`)
  .join('&');

export const getSignedUrl = async (
  host: string,
  region: string,
  credentials: AwsCredentialIdentity,
  expiresIn = 900,
): Promise<string> => {
  const service = 'iotdevicegateway';
  const algorithm = 'AWS4-HMAC-SHA256';

  const sigV4 = new SignatureV4({
    sha256: Sha256,
    service,
    region,
    credentials,
  });

  const date = new Date().toISOString().replaceAll(/[:-]|\.\d{3}/gu, '');
  const credentialScope = `${date.slice(0, 8)}/${region}/${service}/aws4_request`;

  const parameters: Record<string, string> = {
    'X-Amz-Algorithm': algorithm,
    'X-Amz-Credential': `${encodeURIComponent(`${credentials.accessKeyId}/${credentialScope}`)}`,
    'X-Amz-Date': date,
    'X-Amz-Expires': expiresIn.toString(),
    'X-Amz-SignedHeaders': 'host',
  };

  const path = '/mqtt';
  const headers = `host:${host}\n`;
  const canonicalRequest = `GET\n${path}\n${toQueryString(parameters)}\n${headers}\nhost\n${sha256('')}`;
  const stringToSign = `${algorithm}\n${date}\n${credentialScope}\n${sha256(canonicalRequest)}`;

  parameters['X-Amz-Signature'] = await sigV4.sign(stringToSign);
  if (credentials.sessionToken) {
    parameters['X-Amz-Security-Token'] = encodeURIComponent(credentials.sessionToken);
  }

  return `wss://${host}${path}?${toQueryString(parameters)}`;
};

Enter fullscreen mode Exit fullscreen mode

I can now create a Lambda with the following code:

import { type APIGatewayProxyResultV2 } from 'aws-lambda';
import { getSignedUrl } from '../../Service/IoTCore.js';

export const handle = async (): Promise<APIGatewayProxyResultV2> => ({
  statusCode: 200,
  body: JSON.stringify({
    url: await getSignedUrl(process.env.AWS_IOT_HOST ?? '', process.env.AWS_DEFAULT_REGION ?? '', {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
      sessionToken: process.env.AWS_SESSION_TOKEN ?? undefined,
    }),
  }),
});
Enter fullscreen mode Exit fullscreen mode

and when calling it, I receive a URL I can use to connect with something like a Paho MQTT Client. For this example I've set a AWS_IOT_HOST environment variable with the endpoint of IoT Core (xxxxxxx-ats.iot.eu-west-1.amazonaws.com). The endpoint can be found by navigating to the IoT Core service in the dashboard and then going to Settings:

IoT Core host

You can of course also use the IoT Core SDK to retrieve the endpoint.

Connecting

Now that you have a way of getting a presigned url, you want to use it to connect to IoT Core. To do this from a browser, you can use the Paho MQTT library and following snippet of code:

const client = new Paho.MQTT.Client(url, clientId);
client.connect({
  useSSL: true,
  timeout: 3,
  mqttVersion: 4,
  onSuccess: function () {
    console.log("connected");
  },
  onFailure: function () {
    console.log("failed to connect");
  },
});
client.onMessageArrived = function (message) {
  console.log(message);
};
client.onConnectionLost = function (e) {
  console.log("lost connection", e);
};
Enter fullscreen mode Exit fullscreen mode

The url is the response from your Lambda. Make sure the clientId is allowed by your policy as otherwise it will refuse to connect.

You can subscribe to a topic like this:

client.subscribe('updates/"');
Enter fullscreen mode Exit fullscreen mode

and publish a message like this:

const message = new Paho.MQTT.Message(payload);
message.destinationName = 'updates/messages';
client.send(message);
Enter fullscreen mode Exit fullscreen mode

Please note, once again, doing anything not allowed by your policy will result in a disconnect.

Caching

Presigned urls from a Lambda only work for a limited time due to how IAM works but it's possible to cache it in a CDN like CloudFront for a couple of minutes. This way you do not need to generate a fresh url for every visitor. With my configuration, the presigned urls are valid for a maximum of 5 minutes. In practice, I cache them for 1 minute to be on the very safe side.

Summary

You now have a basic way of working with IoT Core that allows for powerful bidirectional communication. Have fun!

In part 3 I will explain how a custom authorizer can be used to do more fine grained permissions per client.

Top comments (1)

Collapse
 
sdeby profile image
Serge Eby

Thanks, Robert for sharing. Can't wait to read the next parts!