Front-End Web & Mobile

Building an application with AWS Amplify, Amazon Cognito, and an OpenID Connect Identity Provider

This post was written by Carlos Perea – Global Cloud Infrastructure Architect at AWS, Krithivasan Balasubramaniyan – Senior Consultant at AWS, and Edvin Hallvaxhiu – Security Consultant at AWS

AWS Amplify is an end-to-end solution that enables mobile and front-end web developers to build and deploy secure, scalable full stack applications, powered by AWS.

AWS Enterprise customers would like to authenticate and authorize their mobile/web applications using a third party OpenID connect identity provider (OIDC). This blog post will provide an approach for an end to end integration of serverless applications built using AWS Amplify and Amazon Cognito with a third party OIDC provider like Okta. This blogpost would also describe how to approach authorization using a custom lambda authorizer which will provide quota enforcement per user and role based access control.

Overview of the Solution

The serverless web application hosted within the Amplify Framework, will utilize the Amplify libraries to authenticate their federated users against the configured Cognito user pool and app client.

As a backend resource, an Amazon API Gateway mock integration is configured. Additionally, a custom AWS Lambda authorizer provides quota enforcement per user and role based access control at the API Gateway. This solution once deployed will allow a federated user to log in to the web application and consume the backend resource.

  1. User logs in to the web application which performs a redirect to the Okta captive Portal.
    1. Upon successful authentication, Cognito will receive a code grant.
    2. The code grant is negotiated for a JWT token with Okta.
    3. The user is created in the Cognito user pool and user attributes are filled based on the attribute mappings.
    4. A Cognito JWT token is returned to the application.
  2. User makes a call to the backend resource (API Gateway).
    1. The application extracts the ID token from JWT and passes the token in the Authorization header of the API.
    2. The API gateway invokes the custom Lambda authorizer and passes the token for further validation.
  3. A series of checks are performed by the custom authorizer
    1. Is the token valid?
    2. Is the user authorized based on the mapped attributes?
    3. Is the user within the daily quota for the number of calls made to the API?
  4. If all above steps succeed then the user is able to consume the API.

Implementation

This section of the blogpost will walk you through the various steps in implementing the solution.

Step 1: Okta

For this implementation we rely on Okta as the Identity Provider.

For information on how to Setup Okta as an OpenID Connect identity provider in a Cognito user pool please refer to the AWS Knowledge Center article here.

Additionally, a custom attribute “department” has been added to Okta user profile. For information on how to add custom attributes to Okta user profile refer to Okta documentation. This attribute will later be used to enforce role-based access for users who want to consume the API Gateway resource.

Step 2: Backend Resources

CloudFormation Stack

The backend resources are created via CloudFormation. The CloudFormation template and the source code for the Lambda function and layers is available in GitHub.

Following resources are part of the CloudFormation stack:

  • Lambda Function acting as the Authorizer
  • Lambda Layer containing dependencies for the Authorizer – congnitojwt and dynamodb-json
  • DynamoDB table to store the quota usage
  • DynamoDB table to store mapping between API resources and department attribute
  • Cognito User Pool
  • API Gateway
  • IAM Roles and Policies

All deployments are done using Makefile, following commands are available:

  • make build – Packages the local artifacts (local paths) that your AWS CloudFormation template references.
  • make apply – Deploys the specified AWS CloudFormation template by creating and then executing a change set.
  • make deploy – executes make build and make apply

The following parameters needs to be configured in the Makefile:

  • AWS_REGION – AWS region where the solution will be deployed
  • PROFILE – Named profile that will apply to the AWS CLI command
  • STACK_NAME – CloudFormation stack name
  • OKTA_CLIENT_ID – OKTA application ID
  • OKTA_CLIENT_SECRET – OKTA application secret
  • COGNITO_DOMAIN – Cognito domain prefix name
  • DEFAULT_CALL_LIMIT – Default daily call limit per user
  • ARTEFACT_S3_BUCKET – S3 bucket where the infrastructure code will be stored. (! The bucket must be created ahead in the same region where the solution lives)
  • ROLE_ATTRIBUTE – The user attribute we will use for Role based access control check (default to department)
  • USERNAME – The Cognito Attribute that will act as the user username (default to Email)

After deploying the CloudFormation Stack as a last step a new item needs to be manually created in the DynamoDB DdbResourceRolesRelationship table. The item maps the API GW resource to the Roles which are allowed to consume this resource.

{
  "Resource": "/blog",
  "Roles": [
    "Finance",
    "IT"
  ]
}

After the successful creation of the backend resources it is time to dive deep into the main components.

Note: Unless stated otherwise, all the configuration, integrations and code snippets described below for the backend are automatically provisioned from CloudFormation.

Cognito

Authentication is achieved via Cognito User Pools. Users signs-in through a third-party identity provider (IdP) . In this blogpost, federated login is implemented via Open Id Connect with Okta as IdP.

The screenshot below shows the attribute mapping between those received from Okta and Cognito User Pool. The custom attribute “department” is checked during the authorization process to determine if the user is authorized to consume the API. More information on Identity provider attribute mapping can be found from Cognito Developer Guide.

Custom Lambda Authorizer

Before diving deep into the code logic let’s have a look at the configuration prerequisites on API Gateway. Lambda function expects to receive a Token type event from API Gateway:

{
  "type": "TOKEN",
  "authorizationToken": "JWTtoken",
  "methodArn": "arn:aws:execute-api:<region>:<account_id>:<api_id>/*/GET/blog"
}

The authorization header is what carries the id token. Caching is disabled in order to invoke the Lambda on every call and track consumption of the API.

Now that we have discussed the prerequisites, let’s have a detailed look into the actual Lambda Authorizer function code blocks. The first step for the Lambda function is to verify if the id token is valid. Cognitojwt python module is used to decode and verify the Cognito JWT tokens. The decode method is used to check the signature, verify that the token was issued by the Cognito user pool and check the expiration time of the token.

import cognitojwt
from cognitojwt.exceptions import CognitoJWTException

verified_claims: dict = cognitojwt.decode(
    id_token,
    REGION,
    USERPOOL_ID,
    app_client_id=APP_CLIENT_ID,
    testmode=False  # Disable token expiration check for testing purposes
)

Next, the daily quota of calls for the user is verified. If this is the first call of the day a new item is created in the DynamoDB DdbUsageTable table where usage is tracked. If the user has exceeded the daily quota, a policy document with Deny effect is returned to API Gateway. User receives a HTTP Response 403 and an error message in the body of the HTTP Response.

Code snippet from the quota check:

def lambda_handler(event, context):
...
        if 'Item' not in response:
            ddb.put_item(
                TableName=DDB_USAGE_TABLE,
                Item={'PrincipalId': {'S': username}, 'CallLimit': {'N': DEFAULT_CALL_LIMIT},
                     'Calls': {'N': '1'}, 'TTL': {'N': reset_date}}
                )
        elif int(response['Item']['CallLimit']['N']) > int(response['Item']['Calls']['N']):
            Calls = str(int(response['Item']['Calls']['N']) + 1)
            ddb.update_item(
                TableName=DDB_USAGE_TABLE,
                Key={'PrincipalId': {'S': username}},
                AttributeUpdates={'Calls': {'Value': {'N': Calls}}}
                )
        else:
            errorMsg = "Daily call limit of {} has exceeded for {}".format(response['Item']['CallLimit']['N'], username)
            return deniedResponse(username, 'Deny', methodArn, errorMsg)
...

If the user tries to log in for the first time in the day, a new item will be created in DynamoDB table DdbUsageTable.  The item will look as follows:

{
  "CallLimit": 50,
  "Calls": 1,
  "PrincipalId": "user@example.com",
  "TTL": 1601164740
}

Subsequent API calls from a user with the same PrincipalId, the Calls attribute value will be incremented by 1.

The configured Amazon DynamoDB Time to Live (TTL) allows you to define a per-item timestamp to determine when an item is no longer needed. TTL is configured in the DynamoDB Table to delete all items daily at 23:55 UTC.

The final step is to check if the user is a member of the department which is allowed to consume the API.

def checkRolePermissions(role, path, ddb, DDB_ROLES_TABLE, principalId, methodArn):
    roles = ddb.get_item(TableName=DDB_ROLES_TABLE, Key={'Resource': {'S': path}})['Item']['Roles']['L']
    roles = json_util.loads(roles)
    if role in roles:
        return allowedResponse(principalId, 'Allow', methodArn)
    else:
        error = "{} doesn't have a role attached which is allowed to access the resource: {} ".format(principalId, path)
        return deniedResponse(principalId, 'Deny', methodArn, error)

Mapping between the API resource and department is stored in DynamoDB

{
  "Resource": "/blog",
  "Roles": [
    "Finance",
    "IT"
  ]
}

In order to consume  the “/blog” resource, user must be a member of IT or Finance department. Note: User assignment into departments is done within Okta. If all the conditions above are fulfilled a policy document with Allow effect is returned to API Gateway and user is allowed to consume the API resource.

Step 3: Amplify Frontend Web Application

The frontend application is built using the React Web Framework based on JavaScript. It is recommended that you have Node.js v10.x or later together with npm v5.x or later on your machine. In the Github repository that was cloned earlier to deploy the API backend resources, please navigate to the frontend directory.

├── README.md
├── package.json
├── public
├── src

You will find all frontend related components within the src directory.

As a next step, we would need to initialize and set up Amplify so that it can create the necessary backend services to support the react app. The getting started guide will walk you through the necessary steps to do so, but it will be required to have the Amplify CLI installed:

npm install -g @aws-amplify/cli

Once the CLI in installed, from the root directory of the frontend run the following command to install all the necessary dependencies.

npm install

One can verify that Amplify has been initialized by logging into the AWS console and navigating to the Amplify service. The newly created app will appear in the console:

Deploy and host my-blogpost-app

Once Amplify has been initialized we are now ready to deploy the first backend service. To do this, we can leverage the hosting backend service by running the following command from the root of the project:

amplify add hosting

For the purpose of simplicity the following options “Hosting with Amplify Console” and “Manual Deployment” can be chosen when prompted for the selections.

To  create the necessary backend resources to deploy the application using Amplify, please run the below command

amplify publish

The web application URL is provided as an output which can also be viewed on the console.

Note: After successful deployment of the application, please update the callback and Signout URL in Cognito user pool with the web application URL (Domain from the above screenshot).

At this stage the application is still non-functional as it needs to be updated with the configuration below.

Amplify Configuration

In order to integrate the web application with the backend services: Cognito and API gateway, several parameters must be configured.  Import and load the user pool and app client configuration, as well as the API Gateway endpoint either in App.js or index.js:

import { Amplify } from 'aws-amplify';

Amplify.configure({
  Auth: {
    region: "<enter the region here>",
    userPoolId: "<enter the cognito user pool id here>",
    userPoolWebClientId: "<enter the applicaiton client id>",
    oauth: {
      domain: "<enter here the amazon cognito domain>",
      scope: ["email", "openid", "aws.cognito.signin.user.admin", "profile"],
      redirectSignIn: "<enter here the amplify hosted url>",
      redirectSignOut: "<enter here the amplify hosted url>",
      responseType: "code"
    }
  },
  API: {
    endpoints: [
      {
        name: "MyBlogPostAPI",
        endpoint: "<enter here the API gateway endpoint url>"
      }
    ]
  }
});

Once you set the right values you are ready to publish your code to amplify. You can submit your changes by running the following command:

amplify publish

The following sections will guide you through the code.

Application State

Leverage Amplify’s local eventing system, “Hub” to handle different application states:

import Amplify, {Hub} from "aws-amplify";
  
  useEffect(() => {
    Hub.listen("auth", ({payload: {event, data}}) => {
      switch (event) {
        case "signIn":
        case "cognitoHostedUI":
          setToken("grating...");
          getToken().then(userToken => setToken(userToken.idToken.jwtToken));
          console.log(token);
          break;
        case "signOut":
          setToken(null);
          break;
        case "signIn_failure":
        case "cognitoHostedUI_failure":
          console.log("Sign in failure", data);
          break;
      }
    });
  }, []);

Generating valid identity token upon federated sign-in

In order to consume the API the user will need to authenticate via the federated sign-in portal(Okta Captive Portal). For the redirect to be successful the provider’s name needs to match the Cognito Identity Provider’s name (configured in Step 1).

import {Auth} from "aws-amplify";

<Button
  onClick={() => Auth.federatedSignIn({provider: "Okta"})}
>
  Federated Sign In
</Button>

Redirection to the Okta captive portal for federated sign in.

Once successfully authenticated a user is created in the User Pool with the given attributes.

The user will then receive the following screen:

As part of the web application, the valid identity token can be retrieved by means of the Auth library:

const [token, setToken] = useState(null);

function getToken() {
  return Auth.currentSession()
    .then(session => session)
    .catch(err => console.log(err));
}

getToken().then(userToken => setToken(userToken.idToken.jwtToken));

Make a call to the Backend API

The backend of the web application is the API hosted with the Amazon API Gateway. In order to consume the API a valid identity token must be provided as part of the header. Again the header name needs to match the one configured within the API Gateway integration:

 import {API} from "aws-amplify";
 
 function getData() {
    const apiName = "MyBlogPostAPI";
    const path = "/Dev/blog";
    const myInit = {
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: props.token
      }
    };
    return API.get(apiName, path, myInit);
  }
  
  const response = await getData();

Summary

In this blogpost, we successfully built a mobile/web application using AWS Amplify, Amazon Cognito and an OpenID connect Identity provider. We also implemented a custom lambda authorizer for the API that helped us to enforce quota’s for each user and Role based access control.

About the authors

Carlos Perea is a Global Cloud Infrastructure Architect with AWS Professional Services. He enables customers to become AWSome during their journey to the cloud. When not up in the cloud he enjoys scuba diving deep in the waters.

Krithivasan Balasubramaniyan is Senior Consultant at Amazon Web Services. He enables global enterprise customers in their digital transformation journey and helps architect cloud native solutions.

Edvin Hallvaxhiu is a Security Consultant with AWS Professional Services and is passionate about cybersecurity and automation. He helps customers build secure and compliant solutions in the cloud. Outside work, he likes traveling and sports.