Architecting and implementing serverless application with streaming sensor data: Part 6

ACME Industries is a fictional industrial company which has one facility that runs short 10 minute processes as part of its overall production process. There are two types of frontend applications that allow employees to view data emitted by various sensors during running process:

  • Admin application – allows users to view sensor data, issue  commands, and configure sensor types.
  • Operator application – allows users to only view sensor data, and issue commands.

In Part 5 I explained how the command and control is implemented in ACME Industries application.

In this post I explain how the authentication and authorization is implemented in ACME Industries using the Amazon Cognito service.

The setup instructions for the example application are provided in the Readme.md file.

Please note that this project uses services that are not covered by the AWS Free Tier, and will incur cost.

Implementing user sign-in with Amazon Cognito service in ACME Industries application

Overview

In ACME Industries application, the user sign-in is implemented with the Amazon Cognito user pools component. With a user pool, both administrators and operators sign in to ACME Industries app through Amazon Cognito service. The user pool for ACME Industries –  SensorDataUserPool – is defined in the template.yaml file:

				
					UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: SensorDataUserPool
      MfaConfiguration: "OFF"
      Schema:
        - Name: email
          AttributeDataType: String
          Mutable: true
          Required: true
				
			

Note that, since ACME Industries application is an internal business application, the multi-factor authentication (MFA) is disabled. For external public-facing applications, it is strongly recommended to have MFA enabled. Also, the SensorDataUserPool configuration has one required attribute: user’s email address. However, a production-level application might require additional attributes like user address, phone number, etc.

In order for frontend clients – AdminAppFrontend and OperatorAppFrontend – to be able to access SensorDataUserPool during user sign-in, we need to define UserPoolClient resource in our template:

				
					 UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: SensorDataUserPoolClient
      GenerateSecret: false
      UserPoolId: !Ref UserPool
				
			

The UserPoolClient is given a unique ID, and it is used by a client application for authentication during user sign-in.

The second component of Amazon Cognito service is identity pools. Identity pools enable system administrators to grant application users access to other AWS services. In ACME Industries, the identity pool is used together with SensorDataUserPool to enable client access to IoT Core service with authenticated users’ credentials. More details on this further in this post.

The SensorDataIdentityPool is defined in the template.yaml file:

				
					IdentityPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      IdentityPoolName: SensorDataIdentityPool
      AllowUnauthenticatedIdentities: false
      CognitoIdentityProviders:
        - ClientId: !Ref UserPoolClient
          ProviderName: !GetAtt UserPool.ProviderName
				
			

The diagram from the Amazon Cognito Developer Guide shows a common Amazon Cognito scenario:

To create and deploy SensorDataUserPool and SensorDataIdentityPool resources, follow the instructions in the Readme.md file.

Implementation details of user sign-in

The diagram of a user sign-in, specific to ACME Industries example application, is similar to the diagram above:

I will next provide implementation details for each step:

1. Authenticate and get ID, access, and refresh tokens

After you have deployed the backend resources and built the AdminAppFrontend by following the instructions in the Readme.md file, you can now start adding users to the SensorDataUserPool:

  • Go to Cognito User Pools console and select SensorDataUserPool
  • In the left panel, under ‘General settings’, select ‘Users and groups’: 
  • Click on the ‘Create user’ button to bring up the ‘Create user’ form:
  • Next, enter a user name, valid temporary password, and an email address similar to what is shown.  To keep things simple, uncheck the ‘Send an invitation to this new user?’ and ‘Mark phone number as verified’ checkboxes. In a production-level setting, you probably would require an email and phone number verification. Note that the user email just needs to have a valid syntax but you don’t need to have an email account setup for that user.
  • Click on the ‘Create user’ button at the bottom of the form. The user has been added to the Users directory of SensorDataUserPool :
  • Next, assuming you have the AdminAppFrontend already running on your localhost, click on the ‘LOGIN’ button on the welcome page which will take you to the login page. Enter the user name and temporary password for the user you have created:
  • Click on the ‘LOGIN’ button, which will trigger the ‘submitForm()’ event handler in the Login.vue component. Note that access to Amazon Cognito is implemented with the Amazon Cognito Identity SDK:
				
					const AmazonCognito = require("amazon-cognito-identity-js");
				
			
  • Also, note that the Cognito user pool ID, app client ID, and identity pool ID are stored in appconfig.json file, and are loaded into the Vuex.Store object at the start of the AdminAppFrontend app.
  • In order to authenticate a user, the ‘submitForm()’ method first creates the CognitoUserPool, AuthenticationDetails, and CognitoUser objects:
				
					 const poolData = {
        UserPoolId: this.$store.getters.appConfiguration.userPoolId,
        ClientId: this.$store.getters.appConfiguration.appClientId,
      };
      const userPool = new AmazonCognito.CognitoUserPool(poolData);

      authenticationData = {
        Username: this.username,
        Password: this.password,
      };
      const authenticationDetails = new AmazonCognito.AuthenticationDetails(
        authenticationData
      );

      const userData = {
        Username: this.username,
        Pool: userPool,
      };
      cognitoUser = new AmazonCognito.CognitoUser(userData);
				
			
  • Next, it authenticates user by calling the cognitoUser.authenticateUser() method:
				
					cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: function() {
          console.log("User successfully authenticated");

          cognitoUser.getSession(function(err, session) {
            if (err) {
              console.log("Error: ", err);
              return;
            }
            userSession = session;
            console.log("userSession: ", userSession);
            const token = session.getIdToken().getJwtToken();
            console.log("ID token: ", token);
            // Note that this is just for demo purposes,-
            // only the ID token is used when calling API Gateway
            const accessToken = session.getAccessToken().getJwtToken();
            console.log("access token: ", accessToken);
          });

          AWS.config.region = appStore.getters.appConfiguration.region;
          const userPoolId = appStore.getters.appConfiguration.userPoolId;
          var login = {};

          var loginKey =
            "cognito-idp." + AWS.config.region + ".amazonaws.com/" + userPoolId;
          login[loginKey] = userSession.getIdToken().getJwtToken();
          console.log("login[loginKey]:", login[loginKey]);

          AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: appStore.getters.appConfiguration.identityPoolId,
            Logins: login,
          });

          // Get temporary AWS credentials for authenticated user
          // Note you can also call get() here instead of refresh()
          AWS.config.credentials.refresh((error) => {
            if (error) {
              console.error(error);
            } else {
              var principal = AWS.config.credentials.identityId;
              console.log("IdentityId: " + principal);

              // NOTE that the commented out code should be implemented in an admin script
              // on the backend. It can be triggered when an authenticated user ID
              // gets added to the identity pool.
              // It should not be implemented inside the client application.
              // console.log("Attaching principal policy");
              // new AWS.Iot().attachPrincipalPolicy(
              //   { policyName: "SensorDataPolicy", principal: principal },
              //   function(err, data) {
              //     console.log("data: ", data);
              //     if (err) {
              //       console.error(err); // an error occurred
              //     }
              //   }
              // );

              console.log("AWS.config.credentials:", AWS.config.credentials);

              console.log(
                "Configuration in store: ",
                appStore.getters.appConfiguration
              );
              appStore.dispatch("setAuthCredentials", AWS.config.credentials);
              appStore.dispatch("setAuthenticationStatus", true);
              appStore.dispatch("storeUserPool", userPool);
              appStore.dispatch("storeUserSession", userSession);

              console.log(AWS.config.credentials.accessKeyId);
              console.log(AWS.config.credentials.secretAccessKey);
              console.log(AWS.config.credentials.sessionToken);

              // Connection to IoT happens after redirection
              appRouter.push("/home");
            }
          });
        },
        onFailure: function(err) {
          console.log("Login failed");
          alert(err);
        },
        newPasswordRequired: function(userAttributes, requiredAttributes) {
          // User was signed up by an admin and must provide new
          // password and required attributes, if any, to complete
          // authentication.

          this.dialogprompt = true;

          // the api doesn't accept this field back
          delete userAttributes.email_verified;
          console.log("New password required", requiredAttributes);
          console.log("User attributes: ", userAttributes);

          // store userAttributes in global variable
          sessionUserAttributes = userAttributes;
          // TODO: needs to be implemented as a custom prompt instead
          const userPswd = window.prompt("Please enter password:");

          cognitoUser.completeNewPasswordChallenge(
            (authenticationData.Password = userPswd),
            sessionUserAttributes,
            this
          );

          console.log(
            "Completed new password challenge:",
            authenticationData.Password
          );
        },
      });
				
			
  • The ‘authenticateUser()’ method provides three callbacks:
    • newPasswordRequired (line 82) – gets called when a new user has been added to the Cognito user pool with the account status set to ‘FORCE_CHANGE_PASSWORD’
    • onSuccess (line 2) – gets called when authentication is successful
    • onFailure (line 78) – gets called if authentication fails
  • After submitting the login form, the ‘newPasswordRequired’ callback is called which will prompt user to enter a new password (note that here I just use a simple window prompt; in production, you would obviously need to implement a custom prompt to hide the password):
  • On the server side, after user (jdoe) has been successfully authenticated by the SensorDataUserPool, the user is assigned a unique ID which gets added to the SensorDataIdentityPool:
  • Next, if user authentication was successful, the ‘onSuccess’ callback is called. The authenticated user obtains a session (lines 5 – 18) which contains an ID token that contains user claims, an access token used internally to perform authenticated calls, and a refresh token used internally to refresh the session after it expires each hour. 
  • After successful user authentication, you can check both the ID and access tokens in the Dev Tools Console of your browser:
  • You can also explore the ‘userSession’ object in the Console:

For example, you can check various claims of the ID token:

Notice that the ‘token_use’ claim is set to “id” in the ID token, and to “access” in the access token.

2. Exchange tokens for AWS credentials and session token

  • After the user (jdoe) has been authenticated, his identity can be exchanged for temporary credentials using web identity federation support in the AWS Security Token Service (AWS STS). 
  • The ‘onSuccess’ callback uses AWS.CognitoIdentityCredentials object to exchange the authenticated user identity for credentials using AWS STS:
				
					const userPoolId = appStore.getters.appConfiguration.userPoolId;

var login = {};
var loginKey = "cognito-idp." + AWS.config.region + ".amazonaws.com/" + userPoolId;
login[loginKey] = userSession.getIdToken().getJwtToken();

AWS.config.credentials = new AWS.CognitoIdentityCredentials({
            IdentityPoolId: appStore.getters.appConfiguration.identityPoolId,
            Logins: login,
          });
				
			

Note how the ‘Logins’ property maps the identity provider name (in this case, the user pool ID) to the identity token for this provider.

  • Next, obtain user credentials by calling either get() , or refresh() method on the AWS.CognitoIdentityCredentials object:
				
					// Get temporary AWS credentials for authenticated user
// Note you can also call get() here instead of refresh()
 AWS.config.credentials.refresh((error) => {
    if (error) {
        console.error(error);
    } else {
      var principal = AWS.config.credentials.identityId;
      console.log("IdentityId: " + principal);
      console.log("AWS.config.credentials:", AWS.config.credentials);

      console.log(
                "Configuration in store: ",
                appStore.getters.appConfiguration
              );
      appStore.dispatch("setAuthCredentials", AWS.config.credentials);
      appStore.dispatch("setAuthenticationStatus", true);
      appStore.dispatch("storeUserPool", userPool);
      appStore.dispatch("storeUserSession", userSession);

      console.log(AWS.config.credentials.accessKeyId);
      console.log(AWS.config.credentials.secretAccessKey);
      console.log(AWS.config.credentials.sessionToken);

      // Connection to IoT happens after redirection
              appRouter.push("/home");
      }
});
				
			
  • You can view the user identity ID and the obtained user credentials in the Console window of your browser:
  • The obtained user credentials, together with the session token, are stored in the Vuex.Store object. They are used to authorize user access to IoT Core service as I explain in the next section.

3. Connect to IoT Core with user credentials and session token

In ACME Industries, the frontends use AWS IoT Device SDK for JavaScript to connect to IoT Core after the IoT.vue component is mounted:

				
					const AWSIoTData = require("aws-iot-device-sdk");

mounted: async function() {
    const authcredentials = this.$store.getters.authCredentials;

    try {
      mqttClient = AWSIoTData.device({
        region: AWS.config.region,
        host: this.$store.getters.appConfiguration.iotHost, //can be queried using 'aws iot describe-endpoint --endpoint-type iot:Data-ATS' - doesn't work with just 'describe-endpoint'
        clientId: "sensordata-" + Math.floor(Math.random() * 100000 + 1),
        maximumReconnectTimeMs: 8000,
        debug: false,
        protocol: "wss",
        accessKeyId: authcredentials.accessKeyId,
        secretKey: authcredentials.secretAccessKey,
        sessionToken: authcredentials.sessionToken,
      });
      
      console.log("Created mqttClient device:", mqttClient);
    } catch (err) {
      console.log(err);
    }
				
			

Note that, to make it easier to follow, I removed extra logging from the actual code.

Device is an instance returned by mqtt.Client() implemented in the MQTT.js package. It provides secure connection to the AWS IoT platform to publish and receive MQTT messages. The arguments used in both frontends of ACME Industries application are as follows:

  • region :  used to specify AWS account region (e.g. ‘us-east-1’) when the ‘protocol’ argument is set to 'wss'.
  • host : an AWS IoT endpoint that is used to connect.
  • clientId : the client ID to use to connect to AWS IoT. Note that this client ID must be unique within the AWS account.
  • maximumReconnectTimeMs : the maximum reconnection time in milliseconds (default 128000).
  • debug : set to ‘true’ for verbose logging (default ‘false’).
  • protocol : the connection type, either ‘mqtts’ (default), ‘wss’ (WebSocket/TLS), or ‘wss-custom-auth’ (WebSocket/TLS with custom authentication). Note that when set to ‘wss’, values must be provided for the accessKeyId and secretKey arguments.
  • accessKeyId : specifies the Access Key ID when protocol is set to ‘wss’. This is one of the temporary credentials obtained by exchanging the authenticated user identity using AWS STS.
  • secretKey : specifies the Secret Key when protocol is set to ‘wss’. This is one of the temporary credentials obtained by exchanging the authenticated user identity using AWS STS.
  • sessionToken : specifies the Session Token when protocol is set to ‘wss’ (required when authenticated via Cognito). This is one of the temporary credentials obtained by exchanging the authenticated user identity using AWS STS.
Server-side security configuration to access AWS IoT

In order to have a complete picture of how an mqtt device connects to the AWS IoT platform, it is important to understand the policies that need to be configured on the server side:

  • An identity pool in general must have a set of authenticated and unauthenticated roles. In ACME Industries, the SensorDataIdentityPool has only an authenticated role – CognitoAuthorizedRole – defined in the template.yaml file:
				
					CognitoAuthorizedRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Federated: "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud": !Ref IdentityPool
              "ForAnyValue:StringLike":
                "cognito-identity.amazonaws.com:amr": authenticated
      Policies:
        - PolicyName: "CognitoAuthorizedPolicy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Action:
                  - "cognito-sync:*"
                Resource:
                  !Join [
                    "",
                    [
                      "arn:aws:cognito-sync:",
                      !Ref "AWS::Region",
                      ":",
                      !Ref "AWS::AccountId",
                      ":identitypool/",
                      !Ref IdentityPool,
                    ],
                  ]
              - Effect: Allow
                Action:
                  - iot:Connect
                Resource:
                  !Join [
                    "",
                    [
                      "arn:aws:iot:",
                      !Ref "AWS::Region",
                      ":",
                      !Ref "AWS::AccountId",
                      ":client/sensordata-*",
                    ],
                  ]
              - Effect: Allow
                Action:
                  - iot:Subscribe
                  - iot:Publish
                Resource: "*"
              - Effect: Allow
                Action:
                  - iot:Receive
                Resource:
                  !Join [
                    "",
                    [
                      "arn:aws:iot:",
                      !Ref "AWS::Region",
                      ":",
                      !Ref "AWS::AccountId",
                      ":topic/*",
                    ],
                  ]
				
			
  • After the SensorDataIdentityPool has been deployed, you can see the CognitoAuthorizedRole attached to the identity pool in the Cognito Management Console:

You can also explore the role and its CognitoAuthorizedPolicy in the IAM Management Console:

The policy allows a client device: to connect to an IoT ‘thing’ with the prefix ‘sensordata-‘, subscribe/publish messages, and receive messages from any topic of an IoT resource deployed under an admin user account in a certain region (‘us-east-1’ in this case).

  • Upon successful authentication with the exchanged temporary credentials, the mqtt device client assumes the CognitoAuthorizedRole with CognitoAuthorizedPolicy. This is one of the two policies that an Amazon Cognito authenticated user needs to access AWS IoT.
  • The second policy is an AWS IoT policy that allows fine-grained control to access the AWS IoT Core operations. Quote from the AWS documentation:AWS IoT policies are separate and different from IAM policies. AWS IoT policies apply only to AWS IoT data plane operations.” For example, with the AWS IoT policy you can control what users have access to which IoT ‘things’, and what users can subscribe to, or receive messages from certain topics.
    • The AWS IoT policy for ACME Industries – SensorDataPolicy – is created in the AWS IoT Management Console:
    • Since ACME Industries example application has only one IoT ‘thing’, and all users subscribe and receive messages from all topics, the SensorDataPolicy does not need the granularity that a more complex, production-level application, would have:
				
					{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Publish",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Receive",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Subscribe",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "iot:GetThingShadow",
      "Resource": "*"
    }
  ]
}
				
			
  • As I mentioned above, when a user is authenticated by a Cognito user pool for the first time, the user identity is created and added to a Cognito identity pool. So, one more step that we need to do to connect the mqtt client device to the IoT endpoint, is to attach the SensorDataPolicy to the authenticated Cognito user ID (for ‘jdoe’ this is:  us-east-1:74a309b1-0253-4adc-8ec1-bf991d0a6383). This can be done by either executing a CLI command:
				
					aws iot attach-principal-policy --policy-name SensorDataPolicy --principal us-east-1:74a309b1-0253-4adc-8ec1-bf991d0a6383

// Alternative command (both are valid):
aws iot attach-policy --policy-name SensorDataPolicy --target us-east-1:74a309b1-0253-4adc-8ec1-bf991d0a6383
				
			

Or, programmatically as is shown in the commented out code in the Login.vue component:

				
					var principal = AWS.config.credentials.identityId;
              console.log("IdentityId: " + principal);

// NOTE that the commented out code should be implemented in an admin script
// on the backend. It can be triggered when an authenticated user ID
// gets added to the identity pool.
// It should not be implemented inside the client application.
// console.log("Attaching principal policy");
// new AWS.Iot().attachPrincipalPolicy(
//   { policyName: "SensorDataPolicy", principal: principal },
//   function(err, data) {
//     console.log("data: ", data);
//     if (err) {
//       console.error(err); // an error occurred
//     }
//   }
// );
				
			

Note that attaching an IoT policy to an authenticated Cognito user ID must be done by a backend admin script. For example, the script can be triggered when an authenticated user ID gets added to the Cognito Identity pool.

Client-side listeners for MQTT events

Once a connection is successfully established, an mqtt client expects to receive the ‘connect’ event and to call a callback function:

				
					mqttClient.on("connect", function() {
      console.log("mqttClient connected");

      mqttClient.subscribe(topics.facilitycommand);
      mqttClient.subscribe(topics.sensorsubscribe);
      mqttClient.subscribe(topics.facilityconfigrequest);
      mqttClient.subscribe(topics.sensorInstanceInfoRequest);
      mqttClient.subscribe(topics.procdailystats);
      mqttClient.subscribe(topics.completedprocinfo);
      mqttClient.subscribe(topics.latestminutestats);
    });
				
			

The callback function subscribes to various topics to receive mqtt server messages. You can check in the Console window of your browser if the connection was successful:

In case of a connection error, the mqtt client also listens to the ‘error’ event, and tries to reestablish the connection:

				
					mqttClient.on("error", async function(err) {
      console.log("mqttClient error:", err);

      // Update credentials
      const data = await that.getCreds();
      mqttClient.updateWebSocketCredentials(
        data.Credentials.AccessKeyId,
        data.Credentials.SecretKey,
        data.Credentials.SessionToken
      );
    });
				
			

With the connection established, the mqtt client listens to the ‘message’ events from the topics it is subscribed to:

				
					mqttClient.on("message", function(topic, payload) {
      const payloadEnvelope = JSON.parse(payload.toString());
      //  console.log("IoT::onMessage: ", topic, payloadEnvelope);
      if (topic === topics.facilitycommand) {
        bus.$emit("facilitycommandreceived", payloadEnvelope);
      } else if (topic === topics.facilityconfigrequest) {
        bus.$emit("facilityconfigrequest", payloadEnvelope);
      } else if (topic === topics.sensorInstanceInfoRequest) {
        bus.$emit("sensorInstanceInfoRequest", payloadEnvelope);
      } else if (topic === topics.procdailystats) {
        console.log("Received message for topic: ", topics.procdailystats);
        bus.$emit("procdailystats", payloadEnvelope);
      } else if (topic === topics.completedprocinfo) {
        console.log("Received message for topic: ", topics.completedprocinfo);
        bus.$emit("completedprocinfo", payloadEnvelope);
      } else if (topic === topics.latestminutestats) {
        console.log("Received message for topic: ", topics.latestminutestats);
        bus.$emit("latestminutestats", payloadEnvelope);
      } else {
        bus.$emit("message", payloadEnvelope);
      }
    });
  }
				
			

4. Make API requests with ID token

The ACME Industries application uses HTTP API which integrates with a number of Lambda functions on the backend. When the application frontend calls the API, API Gateway sends the request to the appropriate Lambda function and returns the function’s response to the client.

In order to control client access to its API, ACME Industries uses JSON Web Token (JWT) authorizer – AcmeAppApiAuthorizer – for all routes of its API. The definitions of the HTTP API, the authorizer, and their integration with Lambda functions is provided in the template.yaml file. Here is how the HTTP API – SensorAppApi – is defined:

				
					SensorAppApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      Auth:
        Authorizers:
          AcmeAppApiAuthorizer:
            Type: AWS::ApiGatewayV2::Authorizer
            AuthorizerType: JWT
            IdentitySource: "$request.header.Authorization"
            JwtConfiguration:
              audience:
                - !Ref ApplClientId
              issuer: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPoolId}
        DefaultAuthorizer: AcmeAppApiAuthorizer
      # CORS configuration - this is open for development only and should be restricted in prod.
      # See https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-httpapi-httpapicorsconfiguration.html
      CorsConfiguration:
        AllowMethods:
          - GET
          - POST
          - DELETE
          - OPTIONS
        AllowHeaders:
          - "*"
        AllowOrigins:
          - "*"
				
			

Note that the AcmeAppApiAuthorizer is part of the SensorAppApi definition. It has the following properties defined:

  • AuthorizerType : the ACME Industries uses JSON Web Token, so the value is set to ‘JWT’
  • IdentitySource : identity source for which authorization is requested. It specifies where to extract the JSON Web Token (JWT) from inbound requests. In the AcmeAppApiAuthorizer the property specifies that the token should be extracted from the Authorization header of a request
  • JWTConfiguration : specifies the configuration of a JWT authorizer and it is required for HTTP APIs. It has two properties:
    • audience : specifies a list of the intended recipients of the token. In ACME Industries this is set to the SensorDataUserPoolClient ID.
    • issuer : the base domain of the identity provider that issues JSON Web Tokens. It is set to the SensorDataUserPool domain.

The script below shows how one of the Lambda functions – GetCompletedProcessesFunction – is integrated with the SensorAppApi HTTP API:

				
					GetCompletedProcessesFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/
      Handler: getCompletedProcesses.handler
      Runtime: nodejs14.x
      Timeout: 3
      MemorySize: 128
      Environment:
        Variables:
          DDB_TABLE: !Ref DynamoDBSensortableName
      Policies:
        DynamoDBCrudPolicy:
          TableName: !Ref DynamoDBSensortableName
      Events:
        HttpGet:
          Type: HttpApi
          Properties:
            Path: "/completedProcesses"
            Method: get
            ApiId: !Ref SensorAppApi
				
			

The ‘Events’ property defines how the Lambda function gets integrated with the API Gateway:

  • HttpGet : event that triggers the Lambda function
  • Path : defines the route with which the Lambda function gets integrated
  • ApiId : gets set to the ID of the SensorAppApi

The rest of the Lambda functions defined in the template.yaml file get integrated with the SensorAppApi exactly the same way.

After deploying the ‘sensorapp-streaming-api’ stack, you can explore the SensorAppApi in the API Gateway management console:

You can check the configured routes for the API:

The ‘Authorization’ page shows the details of the attached AcmeAppApiAuthorizer:

You can also check the details of integration with a Lambda function for each route of the API:

Making API requests

In ACME Industries, access to the SensorAppApi HTTP API is controlled by JWT AcmeAppApiAuthorizer. API Gateway validates the JWTs that an ACME Industries frontend submits with API requests. The code snippets from the Home.vue component show how the ‘get’ and ‘post’ requests are implemented.

‘get’ request:

				
					const URL = `${this.$store.getters.appConfiguration.APIendpoint}/completedProcesses`;

const userSession = this.$store.getters.userSession;
let response;
try {
    response = await axios.get(URL, {
      headers: {            
        Authorization: userSession.getIdToken().getJwtToken(),
    },
});
console.log("Got response for completed processes: ", response);
} catch (err) {
    console.log("Error getting completed processes: ", err);
    console.log("Getting completed process stats errror: ", err.message);
}

console.log("response processes list:", response.data);
this.completedProcesses = response.data.map((item) => item.processId);
				
			

‘post’ request:

				
					// Store sensor configurations for the facility in the database:
  try {
    const urlPostConfig = `${this.$store.getters.appConfiguration.APIendpoint}/savefacilitysensorconfig?facilityId=${sensorconfig.facilityId}`;
    const response = await axios.post(
      urlPostConfig,
      {
        payload: {
          sensortypes: sensorconfig.sensortypes,
        },
      },
      {
        headers: {                  
          Authorization: userSession.getIdToken().getJwtToken(),
        },
      }
    );
    console.log("response after post: ", response);
  } catch (err) {
    console.log("error saving sensor configuration:", err);
  }
				
			
  • Both frontends in ACME Industries use the same pattern to make authorized calls to SensorAppApi:
    • the user session object is used to retrieve a JWT ID token
    • set the Authorization header of the request to the ID token

This API Gateway developer guide article describes general workflow of controlling access to HTTP APIs with JWT Authorizers.

Troubleshooting API requests with Postman tool

When making HTTP API calls with an authorizer configured, you may get an HTTP 401 unauthorized response status code if, for example, the Authorization header of the request does not have a valid token:

The response of the failed request may not have enough information about the cause of the failure.

The tool I find useful for troubleshooting HTTP requests is Postman. I am going to show an example of troubleshooting an HTTP request when getting a list of completed processes:

  • I am assuming that you have some familiarity with the tool, and you have created a workspace.
  • First, let’s make a successful request with a valid token that returns a list of completed processes.
  • Log into the AdminAppFrontend app, open the Dev Tools window (F12) and copy the URL for the ‘completedProcesses’ resource:
  • Open the Postman tool and paste the URL as shown (note I am using Postman for Windows Version 10.6.7):
  • In the dropdown list on the left, select the ‘GET’ method.
  • In the Dev Tools Window with the AdminAppFrontend app, under the Console tab, find the ID token and copy it:
  • In the Postman, under the Headers tab, paste the copied ID token as the value of the Authorization header, and make sure that the Content-Type header is set to the ‘application/json’ value, then click the ‘Send’ button:
  • If the request was successful, you will see the response status 200 at the top-right, and the response body will have a list of completed processes.

Next, let’s take a look at the various errors when sending requests with invalid tokens:

  • If we try to send the same request as above after user session has expired, we get back an ‘Unauthorized’ message in the response body:
  • To see the details of the failed request, select the ‘Headers’ item from the dropdown menu of the request response:

We can see the description of the error in the ‘www-authenticate’ header saying that “the token has expired”.

Now, let’s try to send a request with a wrong token in the ‘Authorization’ header:

  • In the Dev Tools Window with the AdminAppFrontend app, under the Console tab, find the session token and copy it:
  • In the Postman, replace the ID token in the ‘Authorization’ header by pasting the copied session token, then click the ‘Send’ button:

We can see the response status code 401, and the ‘www-authenticate’ header has the description of the error.

As you can see, the Postman could be a useful tool to understand and troubleshoot issues when making API requests with authorization enabled.

Conclusion

This post focuses on implementing user sign-in with Amazon Cognito service in ACME Industries application. I provided implementation details of a user sign-in specific to ACME Industries example application. I showed how to:

  1. Authenticate and get ID, access, and refresh tokens
  2. Exchange tokens for AWS credentials and session token
  3. Connect to IoT Core with user credentials and session token
  4. Make API requests with ID token.

I also showed how to define an HTTP API, authorizer, and their integration with Lambda functions in the SAM template file. Finally, I explained how to troubleshoot possible authorization errors with the Postman tool.

This Post Has One Comment

  1. Irina

    Very impressive!

Comments are closed.