AWS Cloud Operations & Migrations Blog

Testing Amazon Cognito backed APIs using Amazon CloudWatch Synthetics

Customers who develop APIs can control access to them using Amazon Cognito user pools as an authorizer. Testing these APIs should take into account the additional security controls in place to effectively validate that the APIs are working, and Amazon CloudWatch Synthetics enables proactive testing of these APIs.

If you are using Amazon Cognito User Pools as an Authorizer with the App Client configured in confidential client mode and want to test the protected APIs, this blog post will guide you step-by-step on how to modify the source code of your canary. It will show you how to first authenticate against the Amazon Cognito user pool and then use the resulting token to call the API.

The steps below leverage the Amazon CloudWatch Synthetics canary with the API blueprint to monitor an API that is backed by an Amazon Cognito User Pool.

Solution Overview

For this blog post, we will create an Amazon API Gateway GET method which has an Amazon Cognito User pool Authorizer. The Client Secret created from the Cognito User Pool Client is stored in AWS Secrets Manager. We then create a CloudWatch Synthetics Canary that first retrieves the client secret from AWS Secrets Manager and uses this secret to authenticate against Amazon Cognito to get a JSON Web Token (JWT) at the /oauth2/token endpoint as the Token Endpoint documentation. The canary then uses the token to make a GET request to the protected API Gateway method.

The CloudWatch Synthetics Canary does the following steps:

  • Gets the client secret by using the GetSecretValue API.
  • Exchanges the client secret for a JWT token by making a POST request at the /oauth2/token endpoint.
  • Uses the JWT token in the Authorization header to make a GET request to the API Gateway method.

Architecture Diagram

Figure 1: Architecture diagram

Prerequisites

The script below automates pre-requisites setup for the environment needed to run the canary, reducing the need to spinz resources manually to test the authentication. To deploy the prerequisites, AWS CloudShell streamlines the process by providing a pre-configured environment with AWS CLI pre-installed. This simplifies the execution of infrastructure as code directly within the AWS Management Console. For this blog post, we recommend using CloudShell; however, you can also use your own command-line tools if you prefer.

To set up the necessary environment for running the canary, follow these steps:

  1. In your AWS Management Console, type CloudShell in the search bar
  2. Select CloudShell and wait for the console to open
  3. Once the command line is loaded, you should be able to a screen similar to the image below
    CloudShell init

Figure 2: CloudShell for command line access to AWS resources and tools directly from a browser

We recommend keeping a separate browser tab for the CloudShell as you keep the session while browsing between AWS Services to execute the steps below.

Solution Walkthrough

Setting up Cognito

Lets run deploy the script to provision the required resources:

First, the script below creates a user pool in Amazon Cognito which will be used as part of the server authentication. Then, it creates a domain for the user pool, allowing a unique URL for JWT token generation. Following this, it sets up a resource server within the user pool, defining scopes for accessing resources. Additionally, it creates a user pool client, generating client credentials for accessing the user pool, which will be used by the Canary script.

To deploy the Cognito part, modify the USER_POOL_DOMAIN variable that is currently set to domain-domain to a globally unique value and copy and paste the code below in your CloudShell.

# Part 1 - Cognito
export USER_POOL_DOMAIN="domain-domain"
export USER_POOL_SCOPE="demo/example"
export API_GATEWAY_STAGE_NAME="dev"
export USER_POOL=$(aws cognito-idp create-user-pool --pool-name cloudwatch-synthetics-demo)
export USER_POOL_ARN=$(echo "$USER_POOL" | jq -r '.UserPool.Arn')
export USER_POOL_ID=$(echo $USER_POOL_ARN | cut -d'/' -f2)
aws cognito-idp create-user-pool-domain --domain $USER_POOL_DOMAIN --user-pool-id $USER_POOL_ID
aws cognito-idp create-resource-server \
--user-pool-id $USER_POOL_ID \
--identifier demo \
--name synthetics-demo-resource-server \
--scopes '[{"ScopeName":"example",  "ScopeDescription":"this is an example scope"}]'
export USER_POOL_CLIENT=$(aws cognito-idp create-user-pool-client \
--user-pool-id $USER_POOL_ID \
--client-name cloudwatch-synthetics-demo-app-client \
--generate-secret \
--allowed-o-auth-flows client_credentials \
--allowed-o-auth-flows-user-pool-client  \
--allowed-o-auth-scopes $USER_POOL_SCOPE \
--supported-identity-providers COGNITO)
export USER_POOL_CLIENT_ID=$(echo "$USER_POOL_CLIENT" | jq -r '.UserPoolClient.ClientId')
export USER_POOL_CLIENT_SECRET=$(echo "$USER_POOL_CLIENT" | jq -r '.UserPoolClient.ClientSecret')
# Cognito section deployed if no errors above

Setting up API Gateway resource

Continuing, the script below sets up an API Gateway instance, creating a RESTful API for the application. It configures an authorizer for the API Gateway, linking it to the previously created Cognito user pool for authentication. The script further defines integration settings for the API Gateway, specifying a proxy to an external HTTP endpoint. The HTTP endpoint can be any valid endpoint since we are using the API Gateway solely as a mechanism for authentication.

Similar to the code snippet above, run the below commands from CloudShell

# Part 2 - API Gateway
export API_GATEWAY=$(aws apigateway create-rest-api --name synthetics-demo-api)
export API_GATEWAY_ID=$(echo "$API_GATEWAY" | jq -r '.id')
export API_GATEWAY_RESOURCE_ID=$(echo "$API_GATEWAY" | jq -r '.rootResourceId')
export API_GATEWAY_AUTHORIZER=$(aws apigateway create-authorizer \
--rest-api-id $API_GATEWAY_ID \
--name CognitoUserPoolsAuthorizer \
--type COGNITO_USER_POOLS \
--provider-arns $USER_POOL_ARN \
--identity-source method.request.header.Authorization )
export API_GATEWAY_AUTHORIZER_ID=$(echo "$API_GATEWAY_AUTHORIZER" | jq -r '.id' )
aws apigateway put-method \
--rest-api-id $API_GATEWAY_ID \
--resource-id $API_GATEWAY_RESOURCE_ID \
--http-method GET \
--authorization-type  COGNITO_USER_POOLS \
--authorizer-id $API_GATEWAY_AUTHORIZER_ID \
--authorization-scopes  demo/example
aws apigateway put-integration \
--rest-api-id $API_GATEWAY_ID \
--resource-id $API_GATEWAY_RESOURCE_ID \
--http-method GET \
--type HTTP_PROXY \
--integration-http-method GET \
--uri "http://petstore-demo-endpoint.execute-api.com/petstore/pets"
export API_GATEWAY_DEPLOYMENT=$(aws apigateway create-deployment --rest-api-id $API_GATEWAY_ID)
export API_GATEWAY_DEPLOYMENT_ID=$(echo "$API_GATEWAY_DEPLOYMENT" | jq -r '.id')
export API_GATEWAY_STAGE=$(aws apigateway create-stage --rest-api-id $API_GATEWAY_ID --stage-name $API_GATEWAY_STAGE_NAME --deployment-id $API_GATEWAY_DEPLOYMENT_ID)
# API Gateway section deployed if no errors above

Setting up Secrets using AWS Secret Manager

The commands below create a secret in the Secrets Manager that contains the User Pool Client Secret, along with other variables which will be used by the canary. This creates the secret using the default AWS managed key for Secrets Manager (aws/secretsmanager). Click here if you would like to learn how to use a customer managed key for your secret.

# Part 3 - Secrets Manager
export APIGW_URL=https://$API_GATEWAY_ID.execute-api.$AWS_DEFAULT_REGION.amazonaws.com/$API_GATEWAY_STAGE_NAME
export SECRETS_MANAGER_SECRET_NAME="example-secret"

export SECRET_ARN=$(aws secretsmanager create-secret --name $SECRETS_MANAGER_SECRET_NAME --secret-string '{"USER_POOL_CLIENT_ID": "'"$USER_POOL_CLIENT_ID"'", "APIGW_URL": "'"$APIGW_URL"'", "USER_POOL_DOMAIN": "'"$USER_POOL_DOMAIN"'", "USER_POOL_SCOPE": "'"$USER_POOL_SCOPE"'", "USER_POOL_CLIENT_SECRET":"'"$USER_POOL_CLIENT_SECRET"'"}' --output text --query ARN)
# Secrets Manager section deployed if no errors above

Creating IAM Policy and Role for canary

An IAM policy to allow access to Secrets Manager resource is also required, which will contain information like client ID, API Gateway URL, user pool ID, domain, and scope. The script below creates two policies and a role, which will be used while creating the canary.

# Part 4 - IAM Policy for the Canary & Outputs
export CANARY_NAME='cognito-protected-api'
cat << EOF > iam_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::cw-syn-results-*-$AWS_REGION/canary/$AWS_REGION/$CANARY_NAME-*/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetBucketLocation"
            ],
            "Resource": [
                "arn:aws:s3:::cw-syn-results-*-$AWS_REGION"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:$AWS_REGION:*:log-group:/aws/lambda/cwsyn-$CANARY_NAME-*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets",
                "xray:PutTraceSegments"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Resource": "*",
            "Action": "cloudwatch:PutMetricData",
            "Condition": {
                "StringEquals": {
                    "cloudwatch:namespace": "CloudWatchSynthetics"
                }
            }
        }
    ]
}
EOF
cat << EOF > trust_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
EOF
# Create the policy document file
cat << EOF > iam_policy_secret.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "secretsmanager:GetSecretValue"
            ],
            "Resource": "$SECRET_ARN",
            "Effect": "Allow"
        }
    ]
}
EOF
IAM_POLICY_SYNTHETIC_NAME=$(aws iam create-policy --policy-name SyntheticsCognitoBlogPostPolicy --policy-document file://iam_policy.json --query 'Policy.Arn' --output text)
IAM_POLICY_SECRET_NAME=$(aws iam create-policy --policy-name SyntheticsCognitoBlogPostPolicySecret --policy-document file://iam_policy_secret.json --query 'Policy.Arn' --output text)
IAM_ROLE_NAME=$(aws iam create-role --role-name SyntheticsCognitoBlogPostRole --assume-role-policy-document file://trust_policy.json --query 'Role.RoleName' --output text)
aws iam attach-role-policy --role-name "$IAM_ROLE_NAME" --policy-arn "$IAM_POLICY_SYNTHETIC_NAME"
aws iam attach-role-policy --role-name "$IAM_ROLE_NAME" --policy-arn "$IAM_POLICY_SECRET_NAME"
# Outputs 
printf "\n\n{\n\\011\"secret_arn\" : \"%s\"\n\\011\"api_gateway_url\" : \"%s\"\n\\011\"role_name\" : \"%s\"\n}\n\n" \
"$SECRET_ARN" "$APIGW_URL" "$IAM_ROLE_NAME"
# IAM Policy section deployed if no errors above

The JSON output produced by the command lines above also includes the Secret ARN, API Gateway URL and the role required for the the new canary, which will be utilized in the subsequent step.

{
        "secret_arn" : "arn:aws:secretsmanager:ap-southeast-2:111122223333:secret:example-secret-2-GKl1XB"
        "api_gateway_url" : "https://1234567890abcdef0.execute-api.ap-southeast-2.amazonaws.com/dev"
        "role_name" : "SyntheticsCognitoBlogPostRole"
}

Testing the API Gateway

Now that we have Amazon Cognito and the API Gateway stitched together, we need to test if the endpoint is requiring an authorization token. To test the endpoint, let’s use the api_gateway_url outputted in the code snipped above to open a page using a web browser.

If everything is correct, you should see the following output in the browser:

{
"message": "Unauthorized"
}

This Unauthorized message is expected since no token is passed in the Authorization header.

Create the CloudWatch Synthetics Canary to test the protected API

Now, let’s create the canary to authenticate using the secrets generated by the create-user-pool-client command used above. The canary will retrieve these secrets in order to generate the JWT token, which can then be used to authenticate against the API.

Follow the steps below to create the canary using the CloudWatch Synthetics

  1. In your AWS Management Console, type CloudWatch in the search bar
  2. Open the Synthetics Canaries menu of the CloudWatch console, which is under Application Signals
  3. Choose Create canary
  4. Select Inline Editor.
  5. Enter cognito-protected-api for the Name, make sure you use this name to match the role created above.
  6. Under Script editor, choose syn-python-selenium-3.0 as the Runtime version
  7. Under Lambda handler enter apiCanaryBlueprint.handler
    CloudWatch Synthetics Create CanaryFigure 3: Create Canary script editor
  8. Paste the following script into the editor. The script first retrieves the API Gateway URL, User Pool Domain, User Pool Client ID, User Pool Client Secret, and User Pool Scope from Secrets Manager. It uses the User Pool Domain, User Pool Client ID, User Pool Client Secret and User Pool Scope to make a POST request to the Amazon Cognito domain with the /oauth2/token path to get a JWT token. With this token, it then makes a GET request against the API Gateway URL, adding the token in the Authorization header with “Bearer <token>”.
    import json
    import http.client
    from selenium.webdriver.common.by import By
    import urllib.parse
    from aws_synthetics.selenium import synthetics_webdriver as syn_webdriver
    from aws_synthetics.common import synthetics_logger as logger
    import os
    import boto3
    import urllib3
    
    pool_manager = urllib3.PoolManager()
    
    client = boto3.client("secretsmanager")
    
    SECRET_ARN = os.getenv("SECRET_ARN")
    region = os.environ["AWS_REGION"]
    
    
    def verify_request(method, url, post_data=None, headers={}):
        parsed_url = urllib.parse.urlparse(url)
        user_agent = str(syn_webdriver.get_canary_user_agent_string())
        if "User-Agent" in headers:
            headers["User-Agent"] = f"{user_agent} {headers['User-Agent']}"
        else:
            headers["User-Agent"] = user_agent
    
        logger.info(
            f"Making request with Method: '{method}' URL: {url}: Data: {json.dumps(post_data)} Headers: {json.dumps(headers)}"
        )
    
        if parsed_url.scheme == "https":
            conn = http.client.HTTPSConnection(parsed_url.hostname, parsed_url.port)
        else:
            conn = http.client.HTTPConnection(parsed_url.hostname, parsed_url.port)
    
        conn.request(method, url, post_data, headers)
        response = conn.getresponse()
        logger.info(f"Status Code: {response.status}")
        logger.info(f"Response Headers: {json.dumps(response.headers.as_string())}")
    
        # Expect 200 status code
        if not response.status or response.status != 200:
            try:
                logger.error(f"Response: {response.read().decode()}")
            finally:
                if response.reason:
                    conn.close()
                    raise Exception(f"Failed: {response.reason}")
                else:
                    conn.close()
                    raise Exception(f"Failed with status code: {response.status}")
    
        # Expect length of response to be 3
        response_decode = eval(response.read().decode())
        logger.info(f"Response: {response_decode}")
        expected_response_len = 3
        if len(response_decode) != expected_response_len:
            conn.close()
            raise Exception(
                f"Expected response length: {expected_response_len} received: {len(response_decode)}"
            )
        logger.info("HTTP request successfully executed.")
    
        logger.info("HTTP request successfully executed.")
        conn.close()
    
    
    def main():
    
        # Get Values required to make API calls
        try:
            response = client.get_secret_value(SecretId=SECRET_ARN)
    
            logger.info("Get Secrets Manager Secret Value")
            secret_string = response.get("SecretString")
            secret_string_formatted = json.loads(secret_string)
    
            APIGW_URL = secret_string_formatted.get("APIGW_URL")
            USER_POOL_DOMAIN = secret_string_formatted.get("USER_POOL_DOMAIN")
            USER_POOL_CLIENT_ID = secret_string_formatted.get("USER_POOL_CLIENT_ID")
            USER_POOL_SCOPE = secret_string_formatted.get("USER_POOL_SCOPE")
            USER_POOL_CLIENT_SECRET = secret_string_formatted.get("USER_POOL_CLIENT_SECRET")
    
        except Exception as error:
            logger.error(f"Secrets Manager Get Secret Value: {error}")
            raise Exception(f"Secrets Manager Get Secret Value: {error}")
    
        # Get token from /oauth2/token endpoint
        try:
            logger.info("Get Token to make APIGW call")
    
            response = pool_manager.request_encode_body(
                "POST",
                f"https://{USER_POOL_DOMAIN}.auth.{region}.amazoncognito.com/oauth2/token",
                encode_multipart=False,
                fields={
                    "grant_type": "client_credentials",
                    "client_id": USER_POOL_CLIENT_ID,
                    "client_secret": USER_POOL_CLIENT_SECRET,
                    "scope": USER_POOL_SCOPE,
                },
            )
            token_response = json.loads(response.data)
            token = token_response.get("access_token")
    
        except Exception as error:
            logger.error(f"Cognito Get Token from /oauth2/token: {error}")
            raise Exception(f"Cognito Get Token from /oauth2/token: {error}")
    
        # Make the request and test
        method1 = "GET"
        postData1 = {}
        headers1 = {"Authorization": f"Bearer {token}"}
    
        verify_request(method1, APIGW_URL, None, headers1)
    
        logger.info("Canary successfully executed.")
    
    
    def handler(event, context):
        logger.info("Selenium Python API canary.")
        main()
  9. In the Environment Variables section, set the following environment variable SECRET_ARN using the secret_arn JSON output generated in the CloudShell as you took note above. Note that the key SECRET_ARN must be in upper case. An example screenshot is provided below:
    CloudWatch Synthetics Environment VariablesFigure 4: Canary environment variables
  10. In the Schedule tab, change the Run canary to 1 minute
  11. In the Access Permissions tab, choose Select an existing role
  12. Select the role SyntheticsCognitoBlogPostRole. This role has sufficient permission to access the Secret Manager as we created above, otherwise the canary would fail due to lack of permission. These role and policies were created above during the prerequisites step.
    CloudWatch Synthetics access permissionsFigure 5: Select an existing role for the Canary
  13. Keep the rest of the configuration as default and then choose Create Canary

The canary may take 1-2 minutes to be created, and you should be able to see the list of canaries in your account.

Checking the results

As per the role attached above, this canary now has permission to gather a JSON containing information such as Cognito User Pool details and the API Gateway URL. The Python Script which you used to create the canary will then utilize this information for authentication and calling the API Gateway URL.

Let’s return to the cognito-protect-api page and verify the script’s status.

In line with the image below, the console shows the Latest run tab as Passed. This signifies the successful generation of a JWT token and authentication against the API Gateway endpoint established during the prerequisites above. As you tested above in your browser, just by opening the API Gateway URL directly, you received the Unauthorized error, however, in the canary it is now authenticated.

Keep in mind: if the canary is returning an error, make sure you created the canary using the syn-python-selenium-3.0 runtime instead of nodejs.

Canary availability

Figure 6: Canary run passed

The following code snippet demonstrates the use of the pool_manager.request_encode_body method to send a request to the Amazon Cognito service and obtain an access token. This token serves as a credential that authorizes access to secured resources. Upon receiving the token, it is extracted from the response and stored.

Subsequently, the obtained token is utilized in the authorization header ('Authorization': f'Bearer {token}') when making a request to an API Gateway (APIGW_URL).

        response = pool_manager.request_encode_body(
            "POST",
            f"https://{USER_POOL_DOMAIN}.auth.{region}.amazoncognito.com/oauth2/token",
            encode_multipart=False,
            fields={
                "grant_type": "client_credentials",
                "client_id": USER_POOL_CLIENT_ID,
                "client_secret": USER_POOL_CLIENT_SECRET,
                "scope": USER_POOL_SCOPE,
            },
        )
        token_response = json.loads(response.data)
        token = token_response.get("access_token")

    except Exception as error:
        logger.error(f"Cognito Get Token from /oauth2/token: {error}")
        raise Exception(f"Cognito Get Token from /oauth2/token: {error}")

    # Make the request and test
    method1 = "GET"
    postData1 = {}
    headers1 = {"Authorization": f"Bearer {token}"}

    verify_request(method1, APIGW_URL, None, headers1) 

If the call completes without any error, the canary passes and the test is completed. However, if an incorrect token is provided, the connection will fail.

Exploring canary logs

The canary executed without any issues, but if you want to delve a little deeper, click on Logs tab for the canary cognito-protected-api to see the output generated by the HTTP Endpoint used in the API Gateway steps above, which is http://petstore-demo-endpoint.execute-api.com/petstore/pets.

The canary log output should display a result similar to the image below, proving that the canary was able to connect to the API Gateway endpoint, which authorized the call using the API Gateway Authorizer before forwarding the call to the petstore demo endpoint.

Canary logs

Figure 7: Canary availability logs

Clean up

This section provides the necessary information for deleting various resources created as part of this post.

To clean up the resources created during this tutorial, follow these steps:

1. Deleting the canary

It is important to note that deleting a canary is irreversible and all associated data and settings will be lost. Make sure you have made all necessary backups or extracted all relevant data before proceeding with the deletion.

To delete a CloudWatch Synthetics canary, follow the Delete canary steps.

2. Deleting the API Gateway

Deleting the API Gateway cleans up the authorizor, method, stages and deployment without us having to do it. Using the CloudShell session created before, run the below command.

aws apigateway delete-rest-api --rest-api-id $API_GATEWAY_ID

3. Deleting the Cognito User Pool

First remove deletion protection, delete the Cognito User Pool Domain, then delete the Cognito User Pool. Using the CloudShell session created before, run the command below.

aws cognito-idp update-user-pool --user-pool-id $USER_POOL_ID --deletion-protection INACTIVE --auto-verified-attributes email
aws cognito-idp delete-user-pool-domain --user-pool-id $USER_POOL_ID --domain $USER_POOL_DOMAIN
aws cognito-idp delete-user-pool --user-pool-id $USER_POOL_ID

4. Delete the Secret

Similar to the steps above, now remove the secret.

aws secretsmanager delete-secret --secret-id $SECRETS_MANAGER_SECRET_NAME --force-delete-without-recovery

Conclusion

In this blog post, we explained how to test cognito protected API Gateway APIs that are configured for confidential clients. This involves using a CloudWatch Synthetics Canary to first retrieve the client secret using the Secrets Manager GetSecretValue API and then exchanging the client secret for a JWT token by making a POST request at the /oauth2/token endpoint. The resulting JWT token can be used in the Authorization header to make a GET request to the API Gateway method.

About the Authors

glenn author image

Glenn Chia Jin Wee

Glenn is a Cloud Architect at AWS. He uses technology to help customers deliver on their desired outcomes in their cloud adoption journey. With a passion for networking, observability, and open source, Glenn champions the sharing of reusable code to accelerate the development of cloud architectures.

matheus author image

Matheus Canela

In his role as Senior Solutions Architect at Amazon Web Services, Matheus advises FinTech companies in the transformation of their technology platforms, helping all levels of engineers to achieve their goals by following the best practices. Before he joined AWS, Matheus was a Senior DevOps Consultant at CMD Solutions, which is a Premier Consulting Partner based in Sydney. Matheus has also worked as a developer and a security specialist. Because helping the community is in his DNA, Matheus organizes .NET meetups and helps the IT.BR community, supporting qualified engineers from Brazil to migrate to Australia.