AWS IoT Core Simplified - Part 3: Custom Authorizer
Tags: aws, iot, mqtt, serverless
Published: 2024-07-01 08:54:17

## Custom Authorizer
While the Presigned URL method can be very effective, sometimes you need a little bit more control. This can be done with a [custom authorizer](https://docs.aws.amazon.com/iot/latest/developerguide/config-custom-auth.html). Simply said, when a custom authorizer is used, IoT Core will invoke a Lambda function which needs to approve or deny the connection.

### Creating
It's quite easy to create an authorizer with CloudFormation:

```yml
IoTAuthorizer:
  Type: AWS::IoT::Authorizer
  Properties:
    AuthorizerFunctionArn: !GetAtt IotAuthorizerLambdaFunction.Arn
    AuthorizerName: myapp-prod-custom
    SigningDisabled: True
    Status: ACTIVE
```

You will need to give it a _unique_ name (this part is very important!) and point to a Lambda function that will receive the connection request and needs to decide if the client is allowed to connect, and with which permissions. I recommend to prefix with the name of the stack and the stage to be sure the name is always unique. We disable token signing as we do not need it for this use case.

### Permissions
Make sure your authorizer has permission to invoke your Lambda function.

```yml
IoTAuthorizerPolicy:
  Type: AWS::Lambda::Permission
  Properties:
    FunctionName: !GetAtt IotAuthorizerLambdaFunction.Arn
    Action: lambda:InvokeFunction
    Principal: iot.amazonaws.com
    SourceArn: !GetAtt IoTAuthorizer.Arn
```

Without this permission, your Lambda function can not be invoked by IoT Core and your client won't be able to connect. This is something you can easily forget and it's quite a pain to figure out why it's not working.

Now, IoT Core knows which custom authorizer to use when connecting.

## Connecting
MQTT clients have the option to provide a _username_ and _password_ when connecting, you will receive these in your custom authorizer. 

### Passing the authorizer
When connecting to IoT Core, you need to send along the _name_ of the custom auhtorizer you want to use. With a websocket connection, this is a query string variable as part of the url:

```
wss://.../mqtt?x-amz-customauthorizer-name={authorizer}
```

Let's have a look at the Paho MQTT JS library again:

```js
const url = 'wss://xxxxxxxx-ats.iot.eu-west-1.amazonaws.com/mqtt?x-amz-customauthorizer-name={authorizer}';
const client = new Paho.MQTT.Client(url, "the_client_id");
client.connect({
  useSSL: true,
  timeout: 3,
  mqttVersion: 4,
  userName: "the_username",
  password: "the_password",
  onSuccess: function () {
    console.log("connected!");
  },
  onFailure: function () {
    console.log('failed');
  }
});
```

### Connecting by host

It is also possible to connect by host (_xxxxxxxx-ats.iot.eu-west-1.amazonaws.com_) and port instead of using the websocket url. Make sure to use port _443_ and then add the authorizer to the username like this:

```
{username}?x-amz-customauthorizer-name={authorizer}
```

This allows you to use "regular" MQTT clients like MQTT.fx to connect as well. For you as developer there is no difference in how the connection is handled, which is good news.

## Request
When connecting with the above connection details, the event from IoT Core in your custom authorizer Lambda looks something like this:

```json
{
    "protocolData": {
        "mqtt": {
            "username": "the_username",
            "password": "dGhlX3Bhc3N3b3Jk",
            "clientId": "the_client_id"
        }
    },
    "protocols": [
        "mqtt"
    ],
    "signatureVerified": false,
    "connectionMetadata": {
        "id": "c979e717-32c4-7309-b46b-56ab0c5e36b4"
    }
}
```
As you can see, it contains the client ID, username and password that was sent when connecting. The password however is base64 encoded so make sure to decode it before using it. Please note that the username and password can be empty as they are not mandatory.

When connecting over a websocket connection you will also receive the query string and headers that were part of the request in an "https" block in the "protocolData". Personally I'm sticking to the username and password since it will work for both websocket clients and other MQTT clients that use the hostname and port.

![Image Server with keys](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/41ba9u8ab0m4xex5jibz.png)

## Response
The response structure for a custom authorizer needs to look like this:

```json
{
  "isAuthenticated": true,
  "principalId": "user1",
  "disconnectAfterInSeconds": 86400,
  "refreshAfterInSeconds": 3600,
  "policyDocuments": [
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": [
            "iot:Connect",
            "iot:Subscribe",
            "iot:Receive"
          ],
          "Resource": [
            "arn:aws:iot:{region}:{account-id}:client/user1-*",
            "arn:aws:iot:{region}:{account-id}:topicfilter/updates/user/user1",
            "arn:aws:iot:{region}:{account-id}:topic/updates/user/user1"
          ]
        }
      ]
    }
  ]
}
```
The most important keys are "isAuthenticated" and "policyDocuments". 

"isAuthenticated" let's IoT Core know if the client is allowed to connect. If this is _false_, the connection is refused. If it's _true_, the connection is allowed and the policy is applied to the connection. In this example, we allow a client to connect with clientId wildcard of "user1-*" and to subscribe and receive messages on topic "updates/user/1". The principalId is only allowed to contain a-z, A-Z, 0-9 with a maximum length of 128.

An example custom authorizer Lambda:

```typescript
import { IoTCustomAuthorizerEvent } from 'aws-lambda';
import { IoTCustomAuthorizerResult } from 'aws-lambda/trigger/iot-authorizer.js';

export const handle = async (event: IoTCustomAuthorizerEvent): Promise<IoTCustomAuthorizerResult> => {
  let isAuthenticated = false;
  const statement = [];

  let { username, password } = event.protocolData?.mqtt ?? {};
  if (username && password) {
    // authenticate any way you like
    isAuthenticated = true;
    statement.push({
      Action: [
        'IoT:Connect',
        'IoT:Subscribe',
        'IoT:Receive',
      ],
      Effect: 'Allow',
      Resource: [
        // allow client to connect with "{username}-{something}
        `arn:aws:iot:*:*:client/${username}-*`,
        // allow client to subscribe to "updates/user/{username}
        `arn:aws:iot:*:*:topicfilter/updates/user/${username}`,
        // allow client to receive messages from "updates/user/{username}
        `arn:aws:iot:*:*:topic/updates/user/${username}`,
      ],
    });
  }

  return {
    isAuthenticated,
    principalId: username ?? 'empty'.replace(/[^a-zA-Z0-9]/ug, '').slice(0, 128),
    disconnectAfterInSeconds: 86400, // 24 hours
    refreshAfterInSeconds: 3600, // 1 hour
    policyDocuments: [
      {
        Version: '2012-10-17',
        Statement: statement,
      },
    ],
  };
};
```

## Authentication

Since this article focuses on IoT Core, I won't cover the authentication process in the example code. Obviously this is not production ready code and there are many ways how you can authenticate your client credentials, to name a few:

- Look up the username in DynamoDB (or other database of choice) and verify the password (make sure to use something like bcrypt for storing and comparing passwords!)
- Pass a [Json Web Token](https://jwt.io/) (access or id token from Cognito or other identity provider) as the password so the Lambda only needs to verify the token and then use what's inside (ie: the subject/user ID).
- Call an external API to verify the credentials.

The great thing is that you can use kind of any method for it. You receive the credentials, verify them _and_ define exactly what the client can do. You can also set up multiple custom authorizers with their own type of authentication. It is a bit more work than the presigned url but this is more flexible if you have clients that need different permissions.

![Image Debugging](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/krtwuogfyms52fltcsur.png)

## Debugging
Sometimes you might not really be sure why your client is not able to connect. Luckily IoT Core allows you to enable logging. At the IoT Core setting page, go to "Manage Logs" and choose the log level you want. When developing the _Debug_ level will be the most helpful. Don't forget to disable the logger once you put something in production.

### Common issues
- IoT Core doesn't have permission to invoke your Lambda function
- The name of the authorizer has a typo in it
- The password wasn't base64 decoded before using it
- The principal ID contains invalid characters
- The policy doesn't allow the client ID to connect
- The response from your Lambda is malformed in other ways

## Up Next
In the next part of the series I will explain how you can leverage rules to do something with the messages sent to the server.