Creating an API with AWS: Part 4: Cognito Authentication

In my post Creating an API with AWS: Part 3: Additional Endpoints, I updated the simple AWS API endpoint with additional endpoints. In this post, I’ll add authentication to the API using AWS Cognito.

Prerequisites

Before you start, you’ll need the following things:

  1. An AWS account
  2. An Administrator IAM User with which to use the AWS CLI
  3. AWS CLI set up on your local machine
  4. The CloudFormation template from my previous post, Creating an API with AWS: Part 3: Additional Endpoints.

Note that AWS will charge you for creating and hosting this API if you don’t have Free Tier on your account.

Step 1: Add a Cognito User Pool to the CloudFormation Template

Starting with the CloudFormation template from my blog post Creating an API with AWS: Part 3: Additional Endpoints, add a new Cognito User Pool resource called apiuserpool under the tagLinkTable resource:

apiuserpool:
      Type: AWS::Cognito::UserPool
      Properties:
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: true
        AutoVerifiedAttributes:
          - email
        UsernameAttributes:
          - email
        UsernameConfiguration:
          CaseSensitive: false
        UserPoolName: apiuserpool

This will allow you to add users with an email address username that can authenticate against the API.

Step 2: Add a Cognito App Client to the CloudFormation Template

Add a new Cognito User Pool Client resource called applicationAPIUserPoolClient under the apiuserpool resource:

applicationAPIUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        AccessTokenValidity: 1
        ClientName: applicationapiclient
        EnableTokenRevocation: true
        ExplicitAuthFlows:
          - ALLOW_USER_PASSWORD_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
          - ALLOW_ADMIN_USER_PASSWORD_AUTH
        GenerateSecret: true
        IdTokenValidity: 1
        PreventUserExistenceErrors: ENABLED
        RefreshTokenValidity: 1
        SupportedIdentityProviders:
          - COGNITO
        UserPoolId: !Ref apiuserpool
      DependsOn:
            - apiuserpool

This user pool client will allow you to get access tokens and id tokens to use to authenticate as a user against the API.

Step 3: Add an API Gateway Authorizer to the CloudFormation Template

Add a new API Gateway Authorizer resource called apiauth under the taglink resource:

apiauth:
      Type: AWS::ApiGateway::Authorizer
      Properties:
        IdentitySource: method.request.header.Authorization
        Name: apiauth
        ProviderARNs:
          - !GetAtt
            - apiuserpool
            - Arn
        RestApiId: !Ref ApplicationAPI
        Type: COGNITO_USER_POOLS
      DependsOn:
            - apiuserpool

This will enable the ApplicationAPI to use the Cognito User Pool apiuserpool when authenticating requests. The authorization token will need to be set in the ‘Authorization’ header when making API requests.

Step 4: Add Authentication to each Endpoint Method in the CloudFormation Template

Update each endpoint method (except the OPTIONS methods) to have AuthorizationType set to COGNITO_USER_POOLS and the AuthorizerId set to the API Authorizer ID, !Ref apiauth. Also update the DependsOn attribute to include apiauth. The ProxyResourceLinkGet method resource is shown as an example:

ProxyResourceLinkGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: GET     
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn: 
            - ApplicationAPI
            - apiauth
            - link

Step 5: Add User Pool ID and Client ID to the Outputs in the CloudFormation Template

Update the Outputs section with two new attributes, UserPoolID and APIClientID:

UserPoolID:
    Description: The Cognito user pool ID
    Value: !Ref apiuserpool

APIClientID:
    Description: The API user pool client ID
    Value: !Ref applicationAPIUserPoolClient

Step 6: Launch the CloudFormation Template

Save the CloudFormation template to a file called setupapidbauth.yml. With the description updated, the whole file should look like the below:

AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a simple API with three endpoints that can be called to POST/GET links, tags and tag links, with a DynamoDB database and Cognito Authentication
Resources:
    linkTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "link_id"
              AttributeType: "S"
            -
              AttributeName: "link"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "link_id"
              KeyType: "HASH"
            -
              AttributeName: "link"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "linkTable"
          GlobalSecondaryIndexes:
            -
              IndexName: "link_index"
              KeySchema:
                -
                  AttributeName: "link"
                  KeyType: "HASH"
                -
                  AttributeName: "link_id"
                  KeyType: "RANGE"
              Projection:
                ProjectionType: "KEYS_ONLY"
              ProvisionedThroughput:
                ReadCapacityUnits: "5"
                WriteCapacityUnits: "5"
          
    tagTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "tag_id"
              AttributeType: "S"
            -
              AttributeName: "tag"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "tag_id"
              KeyType: "HASH"
            -
              AttributeName: "tag"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "tagTable"
          GlobalSecondaryIndexes:
            -
              IndexName: "tag_index"
              KeySchema:
                -
                  AttributeName: "tag"
                  KeyType: "HASH"
                -
                  AttributeName: "tag_id"
                  KeyType: "RANGE"
              Projection:
                ProjectionType: "KEYS_ONLY"
              ProvisionedThroughput:
                ReadCapacityUnits: "5"
                WriteCapacityUnits: "5"

          
    tagLinkTable:
        Type: AWS::DynamoDB::Table
        Properties:
          AttributeDefinitions:
            -
              AttributeName: "tag_id"
              AttributeType: "S"
            -
              AttributeName: "link_id"
              AttributeType: "S"
          KeySchema:
            -
              AttributeName: "tag_id"
              KeyType: "HASH"
            -
              AttributeName: "link_id"
              KeyType: "RANGE"
          ProvisionedThroughput:
            ReadCapacityUnits: "5"
            WriteCapacityUnits: "5"
          TableName: "tagLinkTable"

    apiuserpool:
      Type: AWS::Cognito::UserPool
      Properties:
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: true
        AutoVerifiedAttributes:
          - email
        UsernameAttributes:
          - email
        UsernameConfiguration:
          CaseSensitive: false
        UserPoolName: apiuserpool

    applicationAPIUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        AccessTokenValidity: 1
        ClientName: applicationapiclient
        EnableTokenRevocation: true
        ExplicitAuthFlows:
          - ALLOW_USER_PASSWORD_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
          - ALLOW_ADMIN_USER_PASSWORD_AUTH
        GenerateSecret: true
        IdTokenValidity: 1
        PreventUserExistenceErrors: ENABLED
        RefreshTokenValidity: 1
        SupportedIdentityProviders:
          - COGNITO
        UserPoolId: !Ref apiuserpool
      DependsOn:
            - apiuserpool
                
    requestHandlerLambdaExecutionRole:
        Type: AWS::IAM::Role
        Properties: 
          AssumeRolePolicyDocument:
            Version: "2012-10-17"
            Statement:
                - 
                    Effect: Allow
                    Principal:
                        Service:
                            - "lambda.amazonaws.com"
                    Action:
                        - "sts:AssumeRole"
          Path: "/"
          Policies:
            - 
                PolicyName: "root"
                PolicyDocument:
                    Version: "2012-10-17"
                    Statement:
                        - 
                            Effect: "Allow"
                            Action:
                                - logs:CreateLogGroup
                                - logs:PutLogEvents
                                - logs:CreateLogStream
                            Resource: "*"
            -
                PolicyName: "db_access"
                PolicyDocument:
                    Version: "2012-10-17"
                    Statement:
                        -
                            Effect: "Allow"
                            Action:
                                - dynamodb:DeleteItem
                                - dynamodb:GetItem
                                - dynamodb:PutItem
                                - dynamodb:Scan
                                - dynamodb:UpdateItem
                                - dynamodb:Query
                            Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/*"
          RoleName: requestHandlerLambdaExecutionRole


    requestHandler:
        Type: "AWS::Lambda::Function"
        Properties: 
          Code:
            ZipFile: !Sub |
                import json
                import boto3
                import uuid

                # Establish credentials
                session_var = boto3.session.Session()
                credentials = session_var.get_credentials()

                """ --- Parameters --- """
                # DynamoDB tables, read lambda function environment variables or initialize with defaults if they not exist.
                DYNAMODB_LINK_TABLE = 'linkTable'
                DYNAMODB_TAG_TABLE = 'tagTable'
                DYNAMODB_TAG_LINK_TABLE = 'tagLinkTable'

                # Initialize DynamoDB Client
                dynamodb = boto3.client('dynamodb')


                def store_link(link):
                    link_id = str(uuid.uuid4())
                    dynamodb.put_item(
                        TableName=DYNAMODB_LINK_TABLE,
                        Item={
                            'link_id': {
                                'S': str(link_id)
                            },
                            'link': {
                                'S': str(link)
                            }
                        }
                    )

                    return link_id


                def store_tag(tag):
                    tag_id = str(uuid.uuid4())
                    dynamodb.put_item(
                        TableName=DYNAMODB_TAG_TABLE,
                        Item={
                            'tag_id': {
                                'S': str(tag_id)
                            },
                            'tag': {
                                'S': str(tag)
                            }
                        }
                    )

                    return tag_id


                def store_taglink(tag_id, link_id):
                    dynamodb.put_item(
                        TableName=DYNAMODB_TAG_LINK_TABLE,
                        Item={
                            'tag_id': {
                                'S': str(tag_id)
                            },
                            'link_id': {
                                'S': str(link_id)
                            }
                        }
                    )


                def get_link_id(link):
                    result = dynamodb.query(
                        TableName=DYNAMODB_LINK_TABLE,
                        IndexName='link_index',
                        KeyConditionExpression='link = :link',
                        ExpressionAttributeValues={
                            ':link': {
                                "S": link
                            }
                        }
                    )

                    if result['Count'] > 0:
                        link_id = result['Items'][0]['link_id']['S']
                    else:
                        link_id = store_link(link)

                    return link_id


                def get_tag_id(tag):
                    result = dynamodb.query(
                        TableName=DYNAMODB_TAG_TABLE,
                        IndexName='tag_index',
                        KeyConditionExpression='tag = :tag',
                        ExpressionAttributeValues={
                            ':tag': {
                                "S": tag
                            }
                        }
                    )

                    if result['Count'] > 0:
                        tag_id = result['Items'][0]['tag_id']['S']
                    else:
                        tag_id = store_tag(tag)

                    return tag_id


                def get_tags(tag_id=None):
                    tags = []

                    if tag_id is None:
                        result = dynamodb.scan(
                            TableName=DYNAMODB_TAG_TABLE,
                            Select="SPECIFIC_ATTRIBUTES",
                            ProjectionExpression="tag_id,tag"
                        )
                    else:
                        # Run a query with a filter on tag_id on the tagTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_TABLE,
                            KeyConditionExpression='tag_id = :tag_id',
                            ExpressionAttributeValues={
                                ':tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                    if result['Count'] > 0:
                        for x in range(result['Count']):
                            tag = {}
                            tag['tag_id'] = result['Items'][x]['tag_id']['S']
                            tag['tag'] = result['Items'][x]['tag']['S']
                            tags.append(tag)

                    return tags


                def extract_link_result(result):
                    links = []
                    if result['Count'] > 0:
                        for x in range(result['Count']):
                            link = {}
                            link['link_id'] = result['Items'][x]['link_id']['S']
                            link['link'] = result['Items'][x]['link']['S']
                            links.append(link)

                    return links


                def get_links(tag_id=None, link_id=None):
                    links = []
                    link_id_list = []

                    if tag_id is None and link_id is None:
                        result = dynamodb.scan(
                            TableName=DYNAMODB_LINK_TABLE,
                            Select="SPECIFIC_ATTRIBUTES",
                            ProjectionExpression="link_id,link"
                        )

                        links = extract_link_result(result)

                    elif link_id is not None:
                        # Run a query with a filter on link_id on the linkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_LINK_TABLE,
                            KeyConditionExpression='link_id = :link_id',
                            ExpressionAttributeValues={
                                ':link_id': {
                                    "S": link_id
                                }
                            }
                        )

                        links = extract_link_result(result)
                    else:
                        # Run a query with a filter on tag_id on the tagLinkTable
                        result = dynamodb.query(
                            TableName=DYNAMODB_TAG_LINK_TABLE,
                            KeyConditionExpression='tag_id = :tag_id',
                            ExpressionAttributeValues={
                                ':tag_id': {
                                    "S": tag_id
                                }
                            }
                        )

                        if result['Count'] > 0:
                            for x in range(result['Count']):
                                link_id_list.append(result['Items'][x]['link_id']['S'])

                        # From the retrieved link_ids, retrieve the links from the link table
                        if len(link_id_list) > 0:
                            for link_id in link_id_list:
                                result = dynamodb.query(
                                    TableName=DYNAMODB_LINK_TABLE,
                                    KeyConditionExpression='link_id = :link_id',
                                    ExpressionAttributeValues={
                                        ':link_id': {
                                            "S": link_id
                                        }
                                    }
                                )
                                if result['Count'] > 0:
                                    link = {}
                                    link['link_id'] = link_id
                                    # There should only be one result per link_id
                                    link['link'] = result['Items'][0]['link']['S']
                                    links.append(link)

                    return links


                def error_response(error_message, aws_request_id):
                    return {
                        "statusCode": 500,
                        "isBase64Encoded": False,
                        "body": json.dumps({
                            "Error": error_message,
                            "Reference": aws_request_id,
                        }),
                        "headers": {
                            'Access-Control-Allow-Origin': '*',
                        },
                    }


                def bad_request_response(error_message, aws_request_id):
                    return {
                        "statusCode": 400,
                        "isBase64Encoded": False,
                        "body": json.dumps({
                            "Error": error_message,
                            "Reference": aws_request_id,
                        }),
                        "headers": {
                            'Access-Control-Allow-Origin': '*',
                        },
                    }


                def lambda_handler(event, context):
                    try:
                        print(event)
                        print(context)
                        return_body = ""
                        if event['resource'] == '/link':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                link = post_body['link']
                                tag = post_body.get('tag')
                                tag_id = post_body.get('tag_id')
                                if tag_id is None:
                                    tag_id = get_tag_id(tag)
                                else:
                                    tags = get_tags(tag_id)
                                    if not tags:
                                        return bad_request_response('Unknown tag_id ' + tag_id, context.aws_request_id)
                                link_id = get_link_id(link)
                                store_taglink(tag_id, link_id)
                                return_body = json.dumps({"link_id": link_id})
                            elif event['httpMethod'] == "GET":
                                tag_id = None
                                link_id = None
                                query_params = event['queryStringParameters']
                                if query_params is not None:
                                    tag_id = query_params.get('tag_id', None)
                                    tag = query_params.get('tag', None)
                                    link_id = query_params.get('link_id', None)
                                    if tag_id is None and tag is not None:
                                        tag_id = get_tag_id(tag)
                                links = get_links(tag_id, link_id)
                                return_body = json.dumps(links)
                        elif event['resource'] == '/tag':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                tag = post_body.get('tag')
                                tag_id = store_tag(tag)
                                return_body = json.dumps({"tag_id": tag_id})
                            elif event['httpMethod'] == "GET":
                                tag_id = None
                                query_params = event['queryStringParameters']
                                if query_params is not None:
                                    tag_id = query_params.get('tag_id', None)
                                    tag = query_params.get('tag', None)
                                    if tag_id is None and tag is not None:
                                        tag_id = get_tag_id(tag)
                                tags = get_tags(tag_id)
                                return_body = json.dumps(tags)
                        elif event['resource'] == '/taglink':
                            if event['httpMethod'] == "POST":
                                post_body = json.loads(event['body'])
                                tag_id = post_body.get('tag_id')
                                link_id = post_body.get('link_id')
                                tags = get_tags(tag_id)
                                links = get_links(tag_id=None, link_id=link_id)
                                if not tags:
                                    return bad_request_response('Unknown tag_id ' + tag_id, context.aws_request_id)
                                if not links:
                                    return bad_request_response('Unknown link_id ' + link_id, context.aws_request_id)
                                store_taglink(tag_id, link_id)
                                return_body = json.dumps({"tag_id": tag_id, "link_id": link_id})
                        # Because this Lambda function is called by an API Gateway proxy integration
                        # the result object must use the following structure.
                        return {
                            "statusCode": 200,
                            "isBase64Encoded": False,
                            "body": return_body,
                            "headers": {
                                'Access-Control-Allow-Origin': '*',
                            },
                        }
                    except Exception as err:
                        print(str(err))

                        # If there is an error during processing, catch it and return
                        # from the Lambda function successfully. Specify a 500 HTTP status
                        # code and provide an error message in the body. This will provide a
                        # more meaningful error response to the end client.
                        return error_response(str(err), context.aws_request_id)

          Description: Lambda function for processing api endpoint requests
          FunctionName: requestHandler
          Handler: index.lambda_handler
          Role: 
            Fn::GetAtt: 
            - "requestHandlerLambdaExecutionRole"
            - "Arn"
          Runtime: python3.8
        DependsOn:
            - requestHandlerLambdaExecutionRole
            
    ApplicationAPI:
        Type: AWS::ApiGateway::RestApi
        Properties:
            Name: ApplicationAPI
            Description: Simple API for an application
        DependsOn: 
            - requestHandler
            
    LambdaPermission:
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !GetAtt 
              - requestHandler
              - Arn
            Action: 'lambda:InvokeFunction'
            Principal: apigateway.amazonaws.com
            SourceArn: !Join ["", [!Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:", !Ref ApplicationAPI, "/*"]]
        DependsOn:
            - ApplicationAPI
            
    link:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'link'
        DependsOn: 
            - ApplicationAPI

    tag:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'tag'
        DependsOn:
            - ApplicationAPI

    taglink:
        Type: AWS::ApiGateway::Resource
        Properties:
            RestApiId: !Ref ApplicationAPI
            ParentId: !GetAtt [ApplicationAPI, RootResourceId]
            PathPart: 'taglink'
        DependsOn:
            - ApplicationAPI

    apiauth:
      Type: AWS::ApiGateway::Authorizer
      Properties:
        IdentitySource: method.request.header.Authorization
        Name: apiauth
        ProviderARNs:
          - !GetAtt
            - apiuserpool
            - Arn
        RestApiId: !Ref ApplicationAPI
        Type: COGNITO_USER_POOLS
      DependsOn:
            - apiuserpool
    
    ProxyResourceLinkGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: GET     
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn: 
            - ApplicationAPI
            - apiauth
            - link
            
    ProxyResourceLinkPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: POST   
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn: 
            - ProxyResourceLinkGET
            - apiauth
              
    OptionsMethodLink:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref link
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    - 
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                - 
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn: 
            - ProxyResourceLinkPOST
            - apiauth

    ProxyResourceTagGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: GET
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceLinkPOST
            - apiauth

    ProxyResourceTagPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: POST
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagGET
            - apiauth

    OptionsMethodTag:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref tag
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    -
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn:
            - ProxyResourceTagPOST
            - apiauth

    ProxyResourceTagLinkGET:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: GET
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagPOST
            - apiauth

    ProxyResourceTagLinkPOST:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: POST
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            Integration:
                Type: AWS_PROXY
                IntegrationHttpMethod: POST
                Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${requestHandler.Arn}/invocations
                IntegrationResponses:
                    -
                        StatusCode: 200
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
        DependsOn:
            - ProxyResourceTagLinkGET
            - apiauth

    OptionsMethodTagLink:
        Type: AWS::ApiGateway::Method
        Properties:
            AuthorizationType: COGNITO_USER_POOLS
            AuthorizerId: !Ref apiauth
            RestApiId: !Ref ApplicationAPI
            ResourceId: !Ref taglink
            HttpMethod: OPTIONS
            Integration:
                IntegrationResponses:
                    -
                        StatusCode: 200
                        ResponseParameters:
                            method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                            method.response.header.Access-Control-Allow-Methods: "'GET,POST,OPTIONS'"
                            method.response.header.Access-Control-Allow-Origin: "'*'"
                        ResponseTemplates:
                            application/json: ''
                PassthroughBehavior: NEVER
                RequestTemplates:
                    application/json: '{"statusCode": 200}'
                Type: MOCK
            MethodResponses:
                -
                    StatusCode: 200
                    ResponseModels:
                        application/json: Empty
                    ResponseParameters:
                        method.response.header.Access-Control-Allow-Headers: true
                        method.response.header.Access-Control-Allow-Methods: true
                        method.response.header.Access-Control-Allow-Origin: true
        DependsOn:
            - ProxyResourceTagLinkPOST
            - apiauth
                
    Deployment: 
      DependsOn: "OptionsMethodTagLink"
      Type: AWS::ApiGateway::Deployment
      Properties: 
        RestApiId: 
          Ref: "ApplicationAPI"
        Description: "Application API deployment"
        StageName: "prod"
Outputs:
  APIBaseUrl:
    Description: Application API Gateway base URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod'

  APILinkUrl:
    Description: Application API Gateway link endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/link'

  APITagUrl:
    Description: Application API Gateway tag endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/tag'

  APITagLinkUrl:
    Description: Application API Gateway taglink endpoint URL
    Value: !Join
        - ''
        - - 'https://'
          - !Ref ApplicationAPI
          - '.execute-api.'
          - !Ref AWS::Region
          - '.amazonaws.com/prod/taglink'

  UserPoolID:
    Description: The Cognito user pool ID
    Value: !Ref apiuserpool

  APIClientID:
    Description: The API user pool client ID
    Value: !Ref applicationAPIUserPoolClient

Launch the CFT with this command:

$ aws cloudformation create-stack --stack-name apidbauth --template-body file://setupapidbauth.yml --capabilities CAPABILITY_NAMED_IAM

Poll for its progress with:

$ aws cloudformation describe-stacks --stack-name apidbauth

When complete, make a note of the APIBaseUrl, UserPoolID and APIClientID values. Export them to environment variables with these commands. Be sure the replace the values in angle brackets with the real values:

$ export BASEURL=<APIBaseUrl>
$ export POOL_ID=<UserPoolID>
$ export CLIENT_ID=<APIClientID>

Step 7: Create a New User

Create a new user with the following commands. Replace <Your email> with your email address and <PASSWORD> with a valid password with at least 8 characters, including 1 upper case character, 1 digit and 1 special character:

$ export EMAIL=<Your email>
$ aws cognito-idp admin-create-user --user-pool-id $POOL_ID --username $EMAIL
$ aws cognito-idp admin-set-user-password --user-pool-id $POOL_ID --username $EMAIL --password <PASSWORD> --permanent

Step 8: Get the Client Secret

Run this command to get the Client Secret for the User Pool Client:

$ aws cognito-idp describe-user-pool-client --client-id $CLIENT_ID --user-pool-id $POOL_ID

You will need to extract the ClientSecret value from the response. Set it to an environment variable:

$ export CLIENT_SECRET=<clientsecret>

Step 9: Generate the Secret Hash

You will need to generate a secret hash, which is used for obtaining id tokens for api requests. Open a file called get_client_secret.py and paste in the following code:

import sys
import hmac, hashlib, base64

username = sys.argv[1]
app_client_id = sys.argv[2]
key = sys.argv[3]
message = bytes(sys.argv[1]+sys.argv[2], 'utf-8')
key = bytes(sys.argv[3], 'utf-8')
secret_hash = base64.b64encode(hmac.new(key, message, digestmod=hashlib.sha256).digest()).decode()

print("SECRET HASH:", secret_hash)

Run this code to generate the hash, with the arguments as shown (you will need python installed locally; run sudo yum install python3 or similar, depending on your operating system):

$ python get_client_secret.py $EMAIL $CLIENT_ID $CLIENT_SECRET

Export the generated hash to an environment variable:

$ export SECRET_HASH=<SECRET HASH>

Step 9: Get the ID Token

To get the ID token for making API requests, run the following. Be sure to replace <PASSWORD> with your user password, and <Your Region> with your AWS region (e.g. us-east-1):

aws cognito-idp initiate-auth --region <Your Region> --auth-flow USER_PASSWORD_AUTH --client-id $CLIENT_ID --auth-parameters USERNAME=$EMAIL,PASSWORD=<PASSWORD>,SECRET_HASH=$SECRET_HASH

The token you will need is the one under the IdToken attribute in the response. Export this to an environment variable:

export TOKEN=<IdToken>

Step 10: Try an Unauthenticated API Request

Now try making a request to the API, without the Authorization header or token:

$ curl -X "POST" -H "Content-Type: application/json" -d "{\"link\": \"https://www.allthecoding.com\", \"tag\": \"blog\"}" $BASEURL/link

You should get the response:

{"message":"Unauthorized"}

Step 11: Try an Authenticated API Request

Now try making a request to the API, this time with the Authorization header and token:

$ curl -X "POST" -H "Content-Type: application/json" -H "Authorization: $TOKEN" -d "{\"link\": \"https://www.allthecoding.com\", \"tag\": \"blog\"}" $BASEURL/link

You should get a response something like the below:

{"link_id": "4343fd21-761c-4717-9f1b-78bd249b4380"}

Step 12: Clean Up

If you don’t want to keep your API (note that AWS will charge you for it if you don’t have Free Tier on your account), delete the CFT stack (all resources created) by running:

$ aws cloudformation delete-stack --stack-name apidbauth

You will then need to remove any logs that were generated by the Lambda function. To do this:

  1. Log into the AWS Console (your AWS account) using your Administrator IAM user, and navigate to CloudWatch.
  2. On the left-hand side, select Logs -> Log Groups.
  3. Select ‘aws/lambda/requestHandler’.
  4. Under Actions, select ‘Delete log groups’ and confirm.

Conclusion

You should now be able to create a simple API on AWS with a CloudFormation template, with three endpoints and three DynamoDB tables. In this post, you learned how to add a Cognito Authorizer, so that only authenticated requests to the API are accepted.

If you want to find out how to add DELETE methods to your API, check out my follow-on post, Creating an API with AWS: Part 5: DELETE methods. Thanks for reading!