AWS IoT Core Simplified - Part 2: Presigned URL
Tags: aws, iot, mqtt, serverless
Published: 2024-06-13 06:25:48

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"
        ]
    }
  ]
}
```
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"
      ]
    }
  ]
}
```

Alternatively, you can of also [publish a message using the AWS SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/iot-data-plane/command/PublishCommand/) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dzffpolwcuofksvg4fp6.png)

## Code
This seems to be quite hidden in the documentation however I did find some [example code](https://github.com/aws-samples/aws-iot-examples/blob/master/mqttSample/js/app.js). 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](https://dev.to/slootjes/optimizing-typescript-packages-in-serverless-framework-with-esbuild-1ol4) to deploy the code so you can run it with a node runtime in Lambda.

```typescript
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)}`;
};

```

I can now create a Lambda with the following code:

```typescript
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,
    }),
  }),
});
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/15d3duf3bu1swvazdjot.png)

You can of course also use the [IoT Core SDK](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/iot/command/DescribeEndpointCommand/) 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](https://github.com/eclipse/paho.mqtt.javascript) and following snippet of code:

```javascript
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);
};
```

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:

```javascript
client.subscribe('updates/"');
```

and publish a message like this:

```javascript
const message = new Paho.MQTT.Message(payload);
message.destinationName = 'updates/messages';
client.send(message);
```

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.