diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/01-bearer-token-injection.ipynb b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/01-bearer-token-injection.ipynb new file mode 100644 index 000000000..d7c309779 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/01-bearer-token-injection.ipynb @@ -0,0 +1,464 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "883d3f31-574f-4ee1-b531-41dd5cc6e348", + "metadata": {}, + "source": [ + "# Bearer Token Injection Pattern for AWS Bedrock AgentCore Gateway Targets" + ] + }, + { + "cell_type": "markdown", + "id": "0ec29593-2f1a-4921-92a5-6641d70b2769", + "metadata": {}, + "source": [ + "## Overview\n", + "\n", + "Customers want to enable an MCP Client(Cyphr, Amazon Q) to invoke tools (e.g., Asana functions) exposed by backend targets (Lambda functions or RESTful services) through the AgentCore Gateway. A critical requirement is to inject a dynamic bearer token from the MCP client into the target for authentication with the downstream service. The key challenge is the mandatory outbound authentication requirement in the AgentCore Gateway, which can conflict with dynamic header injection.\n", + "\n", + "The following architecture diagram shows the solution we are proposing for injecting auth token (e.g. \"Bearer actual-auth-token\") without conflicting with AgentCore outbound auth mechanism.\n", + "\n", + "\"Architecture\n", + "\n", + "In the solution, the MCP client obtains the auth token to be used by the tool and passes it on to the AgentCore Gateway as a parameter in the payload which is then passed on to the tool as a custom header." + ] + }, + { + "cell_type": "markdown", + "id": "ce03190d", + "metadata": {}, + "source": [ + "### Deploying the prerequisites\n", + "\n", + "This solution deploys the following components: \n", + "- Cognito user pool for inbound authentication \n", + "- API Gateway with Lambda integration\n", + "- API Key for AgentCore outbound authentication\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9612d6ae-6f69-4415-a42b-5322b77936b8", + "metadata": {}, + "outputs": [], + "source": [ + "cd prerequisites/agentcore-components" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bc8ee82-890e-4e6c-9daf-04d2b3afd88c", + "metadata": {}, + "outputs": [], + "source": [ + "!bash prereq.sh" + ] + }, + { + "cell_type": "markdown", + "id": "6559974a", + "metadata": {}, + "source": [ + "## Creating AgentCore Gateway with API Gateway as target\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "22fa318a-8fc8-4ecb-b1e2-9d1b871f9abd", + "metadata": {}, + "source": [ + "### Step 1: Install Dependencies and Import \n", + "\n", + "Before we start, let's install the pre-requisites for this lab" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "127d163b-0457-428e-af42-63cd1264507b", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "\n", + "# Get the directory of the current script\n", + "if \"__file__\" in globals():\n", + " current_dir = os.path.dirname(os.path.abspath(__file__))\n", + "else:\n", + " current_dir = os.getcwd() # Fallback if __file__ is not defined (e.g., Jupyter)\n", + "\n", + "print(f\"current_dir set to {current_dir}\")\n", + "\n", + "# Navigate to the directory containing utils.py (two level up to \"04-bearer-token-injection\")\n", + "utils_dir = os.path.abspath(os.path.join(current_dir, \"../..\"))\n", + "\n", + "# Add to sys.path\n", + "sys.path.insert(0, utils_dir)\n", + "\n", + "import utils" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ea729e8-409b-4442-8400-d8aa461d4505", + "metadata": {}, + "outputs": [], + "source": [ + "# Install required packages\n", + "%pip install -U -r ../../requirements.txt -q" + ] + }, + { + "cell_type": "markdown", + "id": "7081d97e-f130-463c-8838-0c0fcc8e3100", + "metadata": {}, + "source": [ + "### Step 2: Create AgentCore Gateway\n", + "\n", + "The following Python code:\n", + "- creates AgentCore Gateway\n", + "- adds the previously created APIGateway with API_KEY as Target." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef6ca2da-5f43-4e81-896f-2a40de7e8f78", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the functions from the agentcore_gateway_creation module\n", + "import sys\n", + "import os\n", + "\n", + "# Add the current directory to Python path to import the module\n", + "current_dir = os.getcwd()\n", + "if current_dir not in sys.path:\n", + " sys.path.insert(0, current_dir)\n", + "\n", + "from agentcore_gateway_creation import create_agentcore_gateway\n", + "\n", + "# Step 1: Create the AgentCore Gateway\n", + "print(\"๐Ÿš€ Creating AgentCore Gateway...\")\n", + "gateway = create_agentcore_gateway()\n", + "print(f\"โœ… Gateway created with ID: {gateway['id']}\")\n", + "print(f\"Gateway URL: {gateway['gateway_url']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "513d4580-e215-4e38-a1e0-80fee69bef0f", + "metadata": {}, + "source": [ + "### Step 3: Add APIGateway as target in AgentCore Gateway\n", + "\n", + "Let us first make sure that the AgentCore Gateway is \"READY\"." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa32c283-a6ab-4d4f-bd88-e98e159f2fb0", + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "import boto3\n", + "\n", + "# Wait for gateway to be in ACTIVE state\n", + "gateway_client = boto3.client(\"bedrock-agentcore-control\")\n", + "while True:\n", + " response = gateway_client.get_gateway(gatewayIdentifier=gateway[\"id\"])\n", + " status = response[\"status\"]\n", + " print(f\"Gateway status: {status}\")\n", + " if status == \"READY\":\n", + " break\n", + " elif status == \"FAILED\":\n", + " raise Exception(\"Gateway creation failed\")\n", + " time.sleep(10) # Wait 10 seconds before checking again" + ] + }, + { + "cell_type": "markdown", + "id": "257f5949-904a-4569-9428-92f5d8ea1510", + "metadata": {}, + "source": [ + "Now let's add the gateway target to complete the setup:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "new-cell-id", + "metadata": {}, + "outputs": [], + "source": [ + "from agentcore_gateway_creation import add_gateway_target\n", + "\n", + "# Step 2: Add Gateway Target\n", + "print(\"๐ŸŽฏ Adding Gateway Target...\")\n", + "add_gateway_target(gateway[\"id\"])\n", + "print(\"โœ… Gateway target configuration completed!\")" + ] + }, + { + "cell_type": "markdown", + "id": "99dc6bbe", + "metadata": {}, + "source": [ + "Let's look at OpenAPI Spec which used to define this target:\n", + "\n", + "\"bearer\n", + "\n", + "The token is defined as a header parameter in the schema and forwarded in an HTTP header: ```X-Asana-Token```. This is just an example, you can define header like this for your integration.\n", + "\n", + "HTTP header is most conventional pattern for authentication tokens." + ] + }, + { + "cell_type": "markdown", + "id": "f8caf0c8", + "metadata": {}, + "source": [ + "### Step 4: Test end to end flow\n", + "\n", + "Let's now test the end to end flow where we will invoke ``` tools/list ``` from a MCP client." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0eb7efe5-412f-4af3-89f2-4a883a3b53a9", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import boto3\n", + "import time\n", + "from botocore.exceptions import ClientError\n", + "import utils\n", + "import uuid\n", + "import json\n", + "import requests\n", + "\n", + "# Step 1: Get inbound Auth token for MCP client to authenticate with AgentCore\n", + "token_url = utils.get_ssm_parameter(\"/app/asana/demo/agentcoregwy/cognito_token_url\")\n", + "client_id = utils.get_ssm_parameter(\"/app/asana/demo/agentcoregwy/machine_client_id\")\n", + "client_secret = utils.get_cognito_client_secret()\n", + "inbound_auth_token = utils.fetch_access_token(client_id, client_secret, token_url)\n", + "print(\"access token received\")\n", + "\n", + "# Step 2: Get Gateway config and prepare payload\n", + "GATEWAY_MCP_URL = gateway_url = (\n", + " f\"https://{utils.get_ssm_parameter('/app/asana/demo/agentcoregwy/gateway_id')}.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp\"\n", + ")\n", + "\n", + "SESSION_ID = str(uuid.uuid4())\n", + "headers = {\n", + " \"Authorization\": f\"Bearer {inbound_auth_token}\", # For Inbound Auth\n", + " \"Content-Type\": \"application/json\",\n", + " \"Mcp-Session-Id\": SESSION_ID,\n", + "}\n", + "\n", + "# Step 3: Call tools/list to get available tools\n", + "list_body = {\"jsonrpc\": \"2.0\", \"id\": \"list-1\", \"method\": \"tools/list\"}\n", + "\n", + "list_response = requests.post(GATEWAY_MCP_URL, headers=headers, json=list_body)\n", + "print(f\"tools/list Status: {list_response.status_code}\")\n", + "print(\"Available Tools:\")\n", + "print(json.dumps(list_response.json(), indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "be27ba3e", + "metadata": {}, + "source": [ + "### Step 5: Bearer token injection from MCP client to AgentCore Gateway target\n", + "\n", + "Now we will pass the bearer token ``` Bearer ASANA-TOKEN ``` in the parameters (you would replace ``` ASANA-TOKEN ``` with the actual token your MCP client would have obtained) and invoke the tool ```AgentCoreGwyAPIGatewayTarget___asanaInvoke``` from ```tools/list``` response above. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "668515a5-356a-45a4-8b64-81666f9d5cd8", + "metadata": {}, + "outputs": [], + "source": [ + "# Step 1: Get inbound Auth token for MCP client to authenticate with AgentCore\n", + "token_url = utils.get_ssm_parameter(\"/app/asana/demo/agentcoregwy/cognito_token_url\")\n", + "client_id = utils.get_ssm_parameter(\"/app/asana/demo/agentcoregwy/machine_client_id\")\n", + "client_secret = utils.get_cognito_client_secret()\n", + "inbound_auth_token = utils.fetch_access_token(client_id, client_secret, token_url)\n", + "print(\"access token received\")\n", + "\n", + "# Step 2: Get Gateway config and prepare payload\n", + "GATEWAY_MCP_URL = gateway_url = (\n", + " f\"https://{utils.get_ssm_parameter('/app/asana/demo/agentcoregwy/gateway_id')}.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp\"\n", + ")\n", + "\n", + "SESSION_ID = str(uuid.uuid4())\n", + "headers = {\n", + " \"Authorization\": f\"Bearer {inbound_auth_token}\", # For Inbound Auth\n", + " \"Content-Type\": \"application/json\",\n", + " \"Mcp-Session-Id\": SESSION_ID,\n", + "}\n", + "\n", + "# Step 3: Call tools/call with Asana token in arguments (will forward as header per updated schema)\n", + "call_body = {\n", + " \"jsonrpc\": \"2.0\",\n", + " \"id\": \"call-1\",\n", + " \"method\": \"tools/call\",\n", + " \"params\": {\n", + " \"name\": \"AgentCoreGwyAPIGatewayTarget___asanaInvoke\", # Exact name from tools/list\n", + " \"arguments\": {\n", + " \"tool_name\": \"createTask\",\n", + " \"X-Asana-Token\": \"Bearer ASANA-TOKEN\", # Actual Asana bearer token here (forwards to header)\n", + " \"name\": \"Test Task from MCP\",\n", + " \"notes\": \"This is a test description\",\n", + " \"project\": \"your-project-gid\", # Replace with actual Project GID\n", + " },\n", + " },\n", + "}\n", + "\n", + "call_response = requests.post(GATEWAY_MCP_URL, headers=headers, json=call_body)\n", + "print(f\"tools/call Status: {call_response.status_code}\")\n", + "print(\"Tool Call Response:\")\n", + "print(json.dumps(call_response.json(), indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "777d3d95", + "metadata": {}, + "source": [ + "### Step 6: Verifying the token injection\n", + "\n", + "Let's verify the token injection by checking the logs in CloudWatch. Navigate to ```AsanaIntegrationStackInfra``` Cloudformation stack created in prerequisites: \n", + "\n", + "```CloudFormation -> Stacks -> AsanaIntegrationStackInfra``` and look for the Lambda function as shown below:\n", + "\"AgentCoreGwyAsanaIntegrationDemo \n", + "\n", + "\n", + "Open the Lambda function in a separate window and click on ```View CloudWatch Logs``` as shown in the screenshot below: \"AgentCoreGwyAsanaIntegrationDemo \n", + "\n", + "\n", + "The following screenshot shows logs from ```AgentCoreGwyAPIGatewayTarget___asanaInvoke``` tool invoke via AgentCore Gateway. It shows that the ``` Bearer token ``` was passed from MCP client to AgentCore Gateway all the way to the Lambda function in header ``` X-Asana-Token ``` as defined in the OpenAPI spec. \n", + "\n", + "\"Bearer\n", + "\n", + "\n", + "This shows that the authentication token obtained by the MCP client can be used by the Lambda function (or another tool) to authenticate with 3rd party SaaS integration like Asana without conflicting with AgentCore outbound authentication.\n", + "\n", + "\n", + "Rest of the payload from MCP client is available to the Lambda function in the body of the event as shown below:\n", + "\"payload" + ] + }, + { + "cell_type": "markdown", + "id": "18038c9a-81ef-4778-9211-647fac66ad29", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "markdown", + "id": "aaf7d685-df5f-4eb3-9177-385f35df0448", + "metadata": {}, + "source": [ + "### Delete AgentCore Gateway" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83f05358-9687-42cd-8037-2dda6d6cb5dc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Deleting all targets for gateway agentcore-gw-asana-integration-gyhxiv6rt5\n" + ] + } + ], + "source": [ + "import utils\n", + "import boto3\n", + "\n", + "gateway_client = boto3.client(\"bedrock-agentcore-control\")\n", + "\n", + "gateway_id = \"agentcore-gw-asana-integration\"\n", + "utils.delete_gateway(gateway_client, gateway_id)" + ] + }, + { + "cell_type": "markdown", + "id": "8e0d85d2-168d-47bb-a2c5-ca2f52199891", + "metadata": {}, + "source": [ + "### Delete Cloudformation stacks" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e29815c1-a6c0-4d56-808c-21ece69f536e", + "metadata": {}, + "outputs": [], + "source": [ + "cd 02-AgentCore-gateway/04-bearer-token-injection" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6eb9a4a1-17d6-414a-9b5e-2ae2c2f658a8", + "metadata": {}, + "outputs": [], + "source": [ + "!bash clean_up.sh" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f468140-a3ce-4fd5-9b8f-50dff7943968", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/README.md b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/clean_up.sh b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/clean_up.sh new file mode 100755 index 000000000..a0ff2817e --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/clean_up.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Enable strict error handling +set -euo pipefail + +# Logging function +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +# ----- Config ----- +INFRA_STACK_NAME=${1:-AsanaIntegrationStackInfra} +COGNITO_STACK_NAME=${2:-AsanaIntegrationStackCognito} +REGION=$(aws configure get region 2>/dev/null || echo "us-east-1") + +log "๐Ÿงน Starting cleanup process..." +log "Region: $REGION" +log "Infrastructure Stack: $INFRA_STACK_NAME" +log "Cognito Stack: $COGNITO_STACK_NAME" + +# Validate AWS CLI is configured +if ! aws sts get-caller-identity >/dev/null 2>&1; then + log "โŒ AWS CLI not configured or credentials invalid" + exit 1 +fi + +# Function to delete a CloudFormation stack +delete_stack() { + local stack_name=$1 + + log "๐Ÿ—‘๏ธ Checking if stack $stack_name exists..." + + # Check if stack exists and get its status + local stack_status + if stack_status=$(aws cloudformation describe-stacks --stack-name "$stack_name" --region "$REGION" --query 'Stacks[0].StackStatus' --output text 2>/dev/null); then + log "๐Ÿ“ฆ Stack $stack_name exists with status: $stack_status" + + # Check if stack is in a deletable state + case "$stack_status" in + "DELETE_IN_PROGRESS") + log "โณ Stack $stack_name is already being deleted, waiting..." + ;; + "DELETE_COMPLETE") + log "โ„น๏ธ Stack $stack_name is already deleted" + return 0 + ;; + "DELETE_FAILED"|"ROLLBACK_COMPLETE"|"ROLLBACK_FAILED"|"CREATE_FAILED") + log "โš ๏ธ Stack $stack_name is in state $stack_status, attempting deletion..." + ;; + esac + + # Attempt to delete the stack + if aws cloudformation delete-stack --stack-name "$stack_name" --region "$REGION" 2>/dev/null; then + log "๐Ÿ“ฆ Deletion initiated for stack: $stack_name" + else + log "โš ๏ธ Failed to initiate deletion for stack: $stack_name" + fi + + log "โณ Waiting for stack $stack_name to be deleted (timeout: 30 minutes)..." + if aws cloudformation wait stack-delete-complete --stack-name "$stack_name" --region "$REGION" --cli-read-timeout 1800 --cli-connect-timeout 60; then + log "โœ… Stack $stack_name deleted successfully" + else + log "โŒ Failed to delete stack $stack_name or operation timed out" + return 1 + fi + else + log "โ„น๏ธ Stack $stack_name does not exist or is already deleted" + fi +} + +# Delete stacks in reverse order (infrastructure first, then cognito) +cleanup_failed=0 + +log "๐Ÿ”ง Deleting infrastructure stack first..." +if ! delete_stack "$INFRA_STACK_NAME"; then + log "โŒ Failed to delete infrastructure stack" + cleanup_failed=1 +fi + +log "๐Ÿ”ง Deleting Cognito stack..." +if ! delete_stack "$COGNITO_STACK_NAME"; then + log "โŒ Failed to delete Cognito stack" + cleanup_failed=1 +fi + +if [ $cleanup_failed -eq 0 ]; then + log "๐ŸŽ‰ Cleanup complete! Both stacks have been deleted successfully." + exit 0 +else + log "โš ๏ธ Cleanup completed with errors. Please check the logs above." + exit 1 +fi \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_log_group.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_log_group.png new file mode 100644 index 000000000..b7aac467d Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_log_group.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_resource.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_resource.png new file mode 100644 index 000000000..982d7f1fc Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/AgentCoreGwyAsanaIntegrationDemo_function_resource.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-body.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-body.png new file mode 100644 index 000000000..93e1a5c95 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-body.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-headers.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-headers.png new file mode 100644 index 000000000..ae67e7f29 Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/Lambda-log-received-headers.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/bearer-token-in-api-spec.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/bearer-token-in-api-spec.png new file mode 100644 index 000000000..4f662b32d Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/bearer-token-in-api-spec.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/token-injection-architecture.png b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/token-injection-architecture.png new file mode 100644 index 000000000..04a665cab Binary files /dev/null and b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/images/token-injection-architecture.png differ diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/agentcore_gateway_creation.py b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/agentcore_gateway_creation.py new file mode 100644 index 000000000..acddd0f5e --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/agentcore_gateway_creation.py @@ -0,0 +1,273 @@ +""" +AgentCore Gateway Creation Module. + +This module handles the creation and configuration of AWS Bedrock AgentCore gateways +for Asana integration, including target configuration and credential management. +""" + +import json +import os +import sys + +import boto3 + +# Add parent directory to path to import utils +current_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.join(current_dir, "..", "..") +sys.path.insert(0, parent_dir) + +try: + from utils import get_ssm_parameter, put_ssm_parameter +except ImportError as e: + print(f"Error importing utils: {e}") + print(f"Current directory: {current_dir}") + print(f"Parent directory: {parent_dir}") + print(f"Python path: {sys.path}") + raise + +STS_CLIENT = boto3.client("sts") + +# Get AWS account details +REGION = boto3.session.Session().region_name + +GATEWAY_CLIENT = boto3.client( + "bedrock-agentcore-control", + region_name=REGION, +) + +print("โœ… Fetching AgentCore gateway!") + +GATEWAY_NAME = "agentcore-gw-asana-integration" + + +def create_agentcore_gateway(): + """Create or retrieve existing AgentCore gateway. + + Returns: + Dictionary containing gateway information (id, name, url, arn) + + Raises: + ValueError: If required SSM parameters are missing + Exception: If gateway creation or retrieval fails + """ + try: + # Validate required SSM parameters exist + machine_client_id = get_ssm_parameter( + "/app/asana/demo/agentcoregwy/machine_client_id" + ) + cognito_discovery_url = get_ssm_parameter( + "/app/asana/demo/agentcoregwy/cognito_discovery_url" + ) + gateway_iam_role = get_ssm_parameter( + "/app/asana/demo/agentcoregwy/gateway_iam_role" + ) + + if not all([machine_client_id, cognito_discovery_url, gateway_iam_role]): + raise ValueError("Required SSM parameters are missing or empty") + + auth_config = { + "customJWTAuthorizer": { + "allowedClients": [machine_client_id], + "discoveryUrl": cognito_discovery_url, + } + } + + # create new gateway + print(f"Creating gateway in region {REGION} with name: {GATEWAY_NAME}") + + create_response = GATEWAY_CLIENT.create_gateway( + name=GATEWAY_NAME, + roleArn=gateway_iam_role, + protocolType="MCP", + authorizerType="CUSTOM_JWT", + authorizerConfiguration=auth_config, + description="Asana Integration Demo AgentCore Gateway", + ) + + gateway_id = create_response["gatewayId"] + + gateway_info = { + "id": gateway_id, + "name": GATEWAY_NAME, + "gateway_url": create_response["gatewayUrl"], + "gateway_arn": create_response["gatewayArn"], + } + put_ssm_parameter("/app/asana/demo/agentcoregwy/gateway_id", gateway_id) + + print(f"โœ… Gateway created successfully with ID: {gateway_id}") + + return gateway_info + + except ( + GATEWAY_CLIENT.exceptions.ConflictException, + GATEWAY_CLIENT.exceptions.ValidationException, + ) as exc: + # If gateway exists, collect existing gateway ID from SSM + print(f"Gateway creation failed: {exc}") + try: + existing_gateway_id = get_ssm_parameter( + "/app/asana/demo/agentcoregwy/gateway_id" + ) + if not existing_gateway_id: + raise ValueError("Gateway ID parameter exists but is empty") from exc + + print(f"Found existing gateway with ID: {existing_gateway_id}") + + # Get existing gateway details + gateway_response = GATEWAY_CLIENT.get_gateway( + gatewayIdentifier=existing_gateway_id + ) + gateway_info = { + "id": existing_gateway_id, + "name": gateway_response["name"], + "gateway_url": gateway_response["gatewayUrl"], + "gateway_arn": gateway_response["gatewayArn"], + } + return gateway_info + except ValueError as ve: + raise ve + except Exception as e: + raise RuntimeError(f"Failed to retrieve existing gateway: {str(e)}") from e + except ValueError as ve: + raise ve + except Exception as e: + raise RuntimeError(f"Unexpected error in gateway creation: {str(e)}") from e + + +def load_api_spec(file_path: str) -> list: + """Load API specification from JSON file. + + Args: + file_path: Path to the JSON file containing API specification + + Returns: + List containing the API specification data + + Raises: + ValueError: If the JSON file doesn't contain a list + """ + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, list): + raise ValueError("Expected a list in the JSON file") + return data + + +def add_gateway_target(gateway_id): + """Add gateway target with API specification and credential configuration. + + Args: + gateway_id: ID of the gateway to add target to + """ + try: + api_spec_file = "../openapi-spec/openapi_simple.json" + + # Validate API spec file exists + if not os.path.exists(api_spec_file): + print(f"โŒ API specification file not found: {api_spec_file}") + sys.exit(1) + + api_spec = load_api_spec(api_spec_file) + print(f"โœ… Loaded API specification file: {api_spec}") + + # Validate API spec structure + if not api_spec or not isinstance(api_spec[0], dict): + raise ValueError("Invalid API specification structure") + + if "servers" not in api_spec[0] or not api_spec[0]["servers"]: + raise ValueError("API specification missing servers configuration") + + api_gateway_url = get_ssm_parameter( + "/app/asana/demo/agentcoregwy/apigateway_url" + ) + + # Validate API Gateway URL + if not api_gateway_url or not api_gateway_url.startswith("https://"): + raise ValueError("Invalid API Gateway URL - must be HTTPS") + + api_spec[0]["servers"][0]["url"] = api_gateway_url + + print(f"โœ… Replaced API Gateway URL: {api_gateway_url}") + + print("โœ… Creating credential provider...") + acps = boto3.client(service_name="bedrock-agentcore-control") + + credential_provider_name = "AgentCoreAPIGatewayAPIKey" + + existing_credential_provider_response = acps.get_api_key_credential_provider( + name=credential_provider_name + ) + provider_arn = existing_credential_provider_response["credentialProviderArn"] + print(f"Found existing credential provider with ARN: {provider_arn}") + + if provider_arn is None: + print( + f"โŒ Credential provider not found, creating new: " + f"{credential_provider_name}" + ) + response = acps.create_api_key_credential_provider( + name=credential_provider_name, + apiKey=get_ssm_parameter("/app/asana/demo/agentcoregwy/api_key"), + ) + + print(response) + credential_provider_arn = response["credentialProviderArn"] + print(f"Outbound Credentials provider ARN, {credential_provider_arn}") + else: + credential_provider_arn = provider_arn + + # API Key credentials provider configuration + api_key_credential_config = [ + { + "credentialProviderType": "API_KEY", + "credentialProvider": { + "apiKeyCredentialProvider": { + # API key name expected by the API Gateway authorizer + "credentialParameterName": "x-api-key", + "providerArn": credential_provider_arn, + # Location of api key - must match API Gateway expectation + "credentialLocation": "HEADER", + # "credentialPrefix": " " # Prefix for token, e.g., "Basic" + } + }, + } + ] + + inline_spec = json.dumps(api_spec[0]) + print(f"โœ… Created inline_spec: {inline_spec}") + # S3 Uri for OpenAPI spec file + agentcoregwy_openapi_target_config = { + "mcp": {"openApiSchema": {"inlinePayload": inline_spec}} + } + print("โœ… Creating gateway target...") + create_target_response = GATEWAY_CLIENT.create_gateway_target( + gatewayIdentifier=gateway_id, + name="AgentCoreGwyAPIGatewayTarget", + description="APIGateway Target for Asana and other 3P APIs", + targetConfiguration=agentcoregwy_openapi_target_config, + credentialProviderConfigurations=api_key_credential_config, + ) + + print(f"โœ… Gateway target created: {create_target_response['targetId']}") + + except GATEWAY_CLIENT.exceptions.ConflictException as exc: + print(f"โŒ Gateway target already exists: {str(exc)}") + # Could implement logic to update existing target if needed + except GATEWAY_CLIENT.exceptions.ValidationException as exc: + print(f"โŒ Validation error creating gateway target: {str(exc)}") + raise + except FileNotFoundError as exc: + print(f"โŒ API specification file not found: {str(exc)}") + raise + except ValueError as exc: + print(f"โŒ Invalid configuration: {str(exc)}") + raise + except Exception as exc: + print(f"โŒ Unexpected error creating gateway target: {str(exc)}") + raise + + +if __name__ == "__main__": + gateway = create_agentcore_gateway() + add_gateway_target(gateway["id"]) diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/cognito.yaml b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/cognito.yaml new file mode 100644 index 000000000..98f6f43d7 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/cognito.yaml @@ -0,0 +1,390 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: "CloudFormation template for AsanaIntegrationDemo System with DynamoDB tables, SSM parameters, and synthetic data" +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Cognito Configuration + Parameters: + - UserPoolName + - MachineAppClientName + - WebAppClientName + +Parameters: + UserPoolName: + Type: String + Default: "AsanaIntegrationDemoGatewayPool" + Description: "Name of the Cognito User Pool" + + MachineAppClientName: + Type: String + Default: "AsanaIntegrationDemoMachineClient" + Description: "Name of the Cognito User Pool Application Client" + + WebAppClientName: + Type: String + Default: "AsanaIntegrationDemoWebClient" + Description: "Name of the Cognito User Pool Web Application Client" + +Resources: + UserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: !Ref UserPoolName + MfaConfiguration: "OFF" # Disable MFA for demo purposes + UsernameConfiguration: + CaseSensitive: false + UsernameAttributes: + - email # <--- Use email as username + AutoVerifiedAttributes: + - email # <--- Auto-verify email if you want to skip confirmation step + # Enhanced security configuration + UserPoolAddOns: + AdvancedSecurityMode: ENFORCED # Enable advanced security features + Policies: + PasswordPolicy: + MinimumLength: 12 + RequireUppercase: true + RequireLowercase: true + RequireNumbers: true + RequireSymbols: true + TemporaryPasswordValidityDays: 1 + AccountRecoverySetting: + RecoveryMechanisms: + - Name: verified_email + Priority: 1 + # LambdaConfig: + # PostConfirmation: !GetAtt PostSignupFunction.Arn + + AdminGroup: + Type: AWS::Cognito::UserPoolGroup + Properties: + GroupName: admin + Description: Administrator group + UserPoolId: !Ref UserPool + Precedence: 1 # Higher priority (lower number = higher precedence) + + CustomerGroup: + Type: AWS::Cognito::UserPoolGroup + Properties: + GroupName: customer + Description: Regular customer group + UserPoolId: !Ref UserPool + Precedence: 2 + + WebUserPoolClient: + DependsOn: ResourceServer + Type: AWS::Cognito::UserPoolClient + Properties: + ClientName: !Ref WebAppClientName + UserPoolId: !Ref UserPool + GenerateSecret: false # Don't use secret for SPA or web apps + AllowedOAuthFlows: + - code + AllowedOAuthScopes: + - openid + - email + - profile + - !Join + - "" + - - "default-m2m-resource-server-" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - "/read" + AllowedOAuthFlowsUserPoolClient: true + CallbackURLs: + - http://localhost:8501/ + - https://example.com/auth/callback + LogoutURLs: + - http://localhost:8501/ + SupportedIdentityProviders: + - COGNITO + AccessTokenValidity: 60 + IdTokenValidity: 60 + RefreshTokenValidity: 30 + TokenValidityUnits: + AccessToken: minutes + IdToken: minutes + RefreshToken: days + EnableTokenRevocation: true + + MachineUserPoolClient: + Type: AWS::Cognito::UserPoolClient + DependsOn: ResourceServer + Properties: + ClientName: !Ref MachineAppClientName + UserPoolId: !Ref UserPool + GenerateSecret: true + ExplicitAuthFlows: + - ALLOW_REFRESH_TOKEN_AUTH + RefreshTokenValidity: 1 + AccessTokenValidity: 60 + IdTokenValidity: 60 + TokenValidityUnits: + AccessToken: minutes + IdToken: minutes + RefreshToken: days + AllowedOAuthFlows: + - client_credentials + AllowedOAuthScopes: + - !Join + - "" + - - "default-m2m-resource-server-" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - "/read" + AllowedOAuthFlowsUserPoolClient: true + SupportedIdentityProviders: + - COGNITO + EnableTokenRevocation: true + + ResourceServer: + Type: AWS::Cognito::UserPoolResourceServer + Properties: + UserPoolId: !Ref UserPool + Identifier: !Join + - "-" + - - "default-m2m-resource-server" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + Name: !Join + - "-" + - - "Default M2M Resource Server " + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + Scopes: + - ScopeName: "read" + ScopeDescription: "An example scope created by Amazon Cognito quick start" + + UserPoolDomain: + Type: AWS::Cognito::UserPoolDomain + Properties: + UserPoolId: !Ref UserPool + Domain: !Join + - "" + - - !Ref "AWS::Region" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + + PostSignupFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + Policies: + - PolicyName: AllowBasicLogs + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/PostSignupFunction*" + - PolicyName: CognitoUserPoolAccessPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - cognito-idp:AdminAddUserToGroup + Resource: !GetAtt UserPool.Arn + - PolicyName: SQSDeadLetterQueuePolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt PostSignupFunctionDLQ.Arn + + # Dead Letter Queue for Lambda function + PostSignupFunctionDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: PostSignupFunction-DLQ + MessageRetentionPeriod: 1209600 # 14 days + KmsMasterKeyId: alias/aws/sqs + + PostSignupFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: PostSignupFunction + Handler: index.lambda_handler + Runtime: python3.13 + Role: !GetAtt PostSignupFunctionRole.Arn + Timeout: 10 + MemorySize: 128 + ReservedConcurrentExecutions: 5 # Limit concurrent executions + DeadLetterConfig: + TargetArn: !GetAtt PostSignupFunctionDLQ.Arn + Environment: + Variables: + LOG_LEVEL: INFO + Code: + ZipFile: | + import boto3 + import json + import logging + import os + + # Configure logging + logger = logging.getLogger() + logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO')) + + def lambda_handler(event, context): + try: + # Validate event structure + if not event or 'userPoolId' not in event or 'userName' not in event: + logger.error("Invalid event structure") + raise ValueError("Missing required event fields") + + user_pool_id = event['userPoolId'] + username = event['userName'] + + # Validate inputs + if not user_pool_id or not username: + logger.error("Empty userPoolId or userName") + raise ValueError("userPoolId and userName cannot be empty") + + client = boto3.client('cognito-idp') + + # Add user to 'Customer' group + client.admin_add_user_to_group( + UserPoolId=user_pool_id, + Username=username, + GroupName='Customer' + ) + logger.info(f"User {username} added to 'Customer' group successfully") + + return event + + except Exception as e: + logger.error(f"Error processing signup: {str(e)}") + raise + + CognitoMachineClientIdParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/machine_client_id + Type: String + Value: !Ref MachineUserPoolClient + Description: Machine Cognito client ID + Tags: + Application: AsanaIntegrationDemo + + CognitoWebClientIdParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/web_client_id + Type: String + Value: !Ref WebUserPoolClient + Description: Cognito client ID for web app + Tags: + Application: CustomerSuppor + + UserPoolIdParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/userpool_id + Type: String + Value: !Ref UserPool + Description: Cognito client ID + Tags: + Application: AsanaIntegrationDemo + + CognitoAuthScopeParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/cognito_auth_scope + Type: String + Value: !Join + - "" + - - "default-m2m-resource-server-" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - "/read" + Description: OAuth2 scope for Cognito auth + Tags: + Application: AsanaIntegrationDemo + + CognitoDiscoveryURLParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/cognito_discovery_url + Type: String + Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}/.well-known/openid-configuration" + Description: OAuth2 Discovery URL + Tags: + Application: AsanaIntegrationDemo + + CognitoTokenURLParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/cognito_token_url + Type: String + Value: !Join + - "" + - - !Sub "https://${AWS::Region}" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - !Sub ".auth.${AWS::Region}.amazoncognito.com/oauth2/token" + Description: OAuth2 Token URL + Tags: + Application: AsanaIntegrationDemo + + CognitoAuthorizeURLParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/cognito_auth_url + Type: String + Value: !Join + - "" + - - !Sub "https://${AWS::Region}" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - !Sub ".auth.${AWS::Region}.amazoncognito.com/oauth2/authorize" + Description: OAuth2 Token URL + Tags: + Application: AsanaIntegrationDemo + + CognitoDomainParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/cognito_domain + Type: String + Value: !Join + - "" + - - !Sub "https://${AWS::Region}" + - !Select [ + 0, + !Split ["-", !Select [2, !Split ["/", !Ref "AWS::StackId"]]], + ] + - !Sub ".auth.${AWS::Region}.amazoncognito.com" + Description: Cognito hosted domain for OAuth2 + Tags: + Application: AsanaIntegrationDemo diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/infrastructure_all.yaml b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/infrastructure_all.yaml new file mode 100644 index 000000000..ecc8a0dd3 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/infrastructure_all.yaml @@ -0,0 +1,557 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: "Comprehensive CloudFormation template for AsanaIntegrationDemo System with Lambda, API Gateway, DynamoDB tables, IAM roles, and SSM parameters" + +Parameters: + UserPoolName: + Type: String + Default: "AsanaIntegrationDemoGatewayPool" + Description: "Name of the Cognito User Pool" + + MachineAppClientName: + Type: String + Default: "AsanaIntegrationDemoMachineClient" + Description: "Name of the Cognito User Pool Application Client" + + WebAppClientName: + Type: String + Default: "AsanaIntegrationDemoWebClient" + Description: "Name of the Cognito User Pool Web Application Client" + +Resources: + # ===== API GATEWAY AND LAMBDA SECTION ===== + + # Lambda Execution Role for API + AsanaIntegrationLambdaRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: AsanaIntegrationLambdaPolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*" + - Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt AsanaIntegrationLambdaDLQ.Arn + + # Dead Letter Queue for Lambda function + AsanaIntegrationLambdaDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "AsanaIntegrationLambda-DLQ-${AWS::StackName}" + MessageRetentionPeriod: 1209600 # 14 days + KmsMasterKeyId: alias/aws/sqs + + # Lambda Function for API + AsanaIntegrationApiLambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: AgentCoreGwyAsanaIntegrationDemo + Runtime: python3.12 + Handler: index.lambda_handler + Role: !GetAtt AsanaIntegrationLambdaRole.Arn + Timeout: 30 + MemorySize: 128 + ReservedConcurrentExecutions: 10 # Limit concurrent executions + DeadLetterConfig: + TargetArn: !GetAtt AsanaIntegrationLambdaDLQ.Arn + Environment: + Variables: + LOG_LEVEL: INFO + Code: + ZipFile: | + import json + import os + import logging + from typing import Dict, Any + from datetime import datetime + + # Configure logging + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Lambda function handler for Asana Integration Demo + + Args: + event: API Gateway event + context: Lambda context + + Returns: + API Gateway response + """ + try: + # Validate event structure + if not isinstance(event, dict): + logger.error("Invalid event structure received") + return create_response(400, {'error': 'Invalid request format'}) + + # Log minimal event info (avoid logging sensitive data) + logger.info(f"Processing request - Method: {event.get('httpMethod', 'UNKNOWN')}, Path: {event.get('path', 'UNKNOWN')}") + + # Get HTTP method and path with validation + http_method = event.get('httpMethod', '').upper() + path = event.get('path', '') + + # Validate required fields + if not http_method or not path: + logger.error("Missing required fields in event") + return create_response(400, {'error': 'Invalid request structure'}) + + # Route based on method and path + if http_method == 'GET' and path == '/asana': + return handle_get_request(event) + elif http_method == 'POST' and path == '/asana': + return handle_post_request(event) + else: + logger.warning(f"Unsupported endpoint: {http_method} {path}") + return create_response(404, {'error': 'Not Found'}) + + except Exception as e: + logger.error(f"Error processing request: {str(e)}") + return create_response(500, {'error': 'Internal Server Error'}) + + def handle_get_request(event: Dict[str, Any]) -> Dict[str, Any]: + """Handle GET requests to /asana endpoint""" + + # Get query parameters + query_params = event.get('queryStringParameters') or {} + + response_data = { + 'message': 'AgentCore Asana Integration Demo', + 'status': 'active', + 'method': 'GET', + 'endpoint': '/asana', + 'query_params': query_params + } + + return create_response(200, response_data) + + def handle_post_request(event: Dict[str, Any]) -> Dict[str, Any]: + """Handle POST requests to /asana endpoint""" + + try: + # Parse request body with size validation + headers = event.get('headers', '{}') + logger.info(f"Headers: {headers}") + + body = event.get('body', '{}') + logger.info(f"POST data: {body}") + + # Validate body size (prevent large payloads) + if isinstance(body, str) and len(body) > 1024 * 1024: # 1MB limit + logger.error("Request body too large") + return create_response(413, {'error': 'Request body too large'}) + + if isinstance(body, str): + if not body.strip(): + post_data = {} + else: + post_data = json.loads(body) + else: + post_data = body if body else {} + + # Validate post_data structure + if not isinstance(post_data, dict): + logger.error("POST data must be a JSON object") + return create_response(400, {'error': 'Request body must be a JSON object'}) + + # Validate required fields + tool_name = post_data.get('tool_name') + if not tool_name or not isinstance(tool_name, str): + logger.error("Missing or invalid tool_name") + return create_response(400, {'error': 'tool_name is required and must be a string'}) + + # Sanitize tool_name (basic validation) + if not tool_name.replace('_', '').replace('-', '').isalnum(): + logger.error("Invalid tool_name format") + return create_response(400, {'error': 'tool_name contains invalid characters'}) + + # Log minimal info (avoid logging sensitive data) + logger.info(f"Processing tool: {tool_name}") + + # Validate headers for bearer token + headers = event.get('headers', {}) + asana_token = headers.get('X-Asana-Token') or headers.get('x-asana-token') + + if not asana_token: + logger.error("Missing X-Asana-Token header") + return create_response(401, {'error': 'X-Asana-Token header is required'}) + + current_datetime = datetime.now() + current_time = current_datetime.strftime("%Y-%m-%d %H:%M:%S") + + # Process POST data (placeholder for actual integration logic) + response_data = { + 'message': 'POST request processed successfully', + 'method': 'POST', + 'endpoint': '/asana', + 'processed': True, + 'tool_name': tool_name, + 'timestamp': current_time + } + + return create_response(200, response_data) + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in POST body: {str(e)}") + return create_response(400, {'error': 'Invalid JSON format'}) + except Exception as e: + logger.error(f"Error processing POST request: {str(e)}") + return create_response(500, {'error': 'Internal server error'}) + + def create_response(status_code: int, body: Dict[str, Any]) -> Dict[str, Any]: + """Create standardized API Gateway response with security headers""" + + return { + 'statusCode': status_code, + 'headers': { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', # Consider restricting in production + 'Access-Control-Allow-Headers': 'Content-Type,X-Asana-Token,Authorization', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }, + 'body': json.dumps(body, separators=(',', ':')) # Compact JSON + } + + # API Gateway + AsanaIntegrationApiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Name: AsanaIntegrationDemoApi + Description: API Gateway for Asana Integration Demo + EndpointConfiguration: + Types: + - REGIONAL + + # API Gateway Resource + AsanaResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref AsanaIntegrationApiGateway + ParentId: !GetAtt AsanaIntegrationApiGateway.RootResourceId + PathPart: asana + + # Note: Custom authorizer removed - AgentCore handles authentication + + # GET Method - No Authorization (AgentCore handles auth) + AsanaGetMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref AsanaIntegrationApiGateway + ResourceId: !Ref AsanaResource + HttpMethod: GET + AuthorizationType: NONE + ApiKeyRequired: false + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AsanaIntegrationApiLambdaFunction.Arn}/invocations" + + # POST Method - No Authorization (AgentCore handles auth) + AsanaPostMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref AsanaIntegrationApiGateway + ResourceId: !Ref AsanaResource + HttpMethod: POST + AuthorizationType: NONE + ApiKeyRequired: false + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AsanaIntegrationApiLambdaFunction.Arn}/invocations" + + # Lambda Permission for API Gateway + AsanaLambdaApiGatewayPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref AsanaIntegrationApiLambdaFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${AsanaIntegrationApiGateway}/*/GET/asana" + + AsanaLambdaApiGatewayPermissionPost: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref AsanaIntegrationApiLambdaFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${AsanaIntegrationApiGateway}/*/POST/asana" + + # Note: Authorizer Lambda permission removed - no longer needed + + # CloudWatch Log Group for API Gateway Access Logs + AsanaApiGatewayLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/apigateway/${AsanaIntegrationApiGateway}" + RetentionInDays: 30 + + # API Gateway Deployment + AsanaApiDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - AsanaGetMethod + - AsanaPostMethod + Properties: + RestApiId: !Ref AsanaIntegrationApiGateway + StageName: Prod + StageDescription: + Description: Production stage with access logging + AccessLogSetting: + DestinationArn: !GetAtt AsanaApiGatewayLogGroup.Arn + Format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","caller":"$context.identity.caller","user":"$context.identity.user","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}' + MethodSettings: + - ResourcePath: "/*" + HttpMethod: "*" + LoggingLevel: INFO + DataTraceEnabled: false + MetricsEnabled: true + ThrottlingRateLimit: 100 + ThrottlingBurstLimit: 200 + + # API Key for AsanaIntegrationApiGateway + AsanaIntegrationApiKey: + Type: AWS::ApiGateway::ApiKey + Properties: + Name: !Sub "AsanaIntegrationDemoApiKey-${AWS::StackName}" + Description: API Key for Asana Integration Demo API Gateway + Enabled: true + + # Usage Plan for API Key + AsanaIntegrationUsagePlan: + Type: AWS::ApiGateway::UsagePlan + DependsOn: AsanaApiDeployment + Properties: + UsagePlanName: AsanaIntegrationDemoUsagePlan + Description: Usage plan for Asana Integration Demo API + ApiStages: + - ApiId: !Ref AsanaIntegrationApiGateway + Stage: Prod + Throttle: + RateLimit: 100 # Reduced from 1000 for better security + BurstLimit: 200 # Reduced from 2000 for better security + Quota: + Limit: 1000 # Reduced from 10000 for better security + Period: DAY + + # Link API Key to Usage Plan + AsanaIntegrationUsagePlanKey: + Type: AWS::ApiGateway::UsagePlanKey + Properties: + KeyId: !Ref AsanaIntegrationApiKey + KeyType: API_KEY + UsagePlanId: !Ref AsanaIntegrationUsagePlan + + # ===== AGENTCORE IAM ROLES SECTION ===== + + # Gateway AgentCore Role + GatewayAgentCoreRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AssumeRolePolicy + Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:* + Policies: + - PolicyName: AsanaIntegrationDemoGatewayAgentCorePolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: ECRImageAccess + Effect: Allow + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + - !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/bedrock_agentcore-asanaintegrationdemo* + - Sid: BedrockAgentCoreAccess + Effect: Allow + Action: + - bedrock-agentcore:GetGateway + - bedrock-agentcore:ListGateways + - bedrock-agentcore:GetGatewayTarget + - bedrock-agentcore:ListGatewayTargets + - bedrock-agentcore:InvokeGateway + Resource: + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:gateway/* + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:gateway-target/* + - Sid: ProvisionedThroughputModelInvocation + Effect: Allow + Action: + - bedrock:InvokeModel + - bedrock:InvokeModelWithResponseStream + Resource: arn:aws:bedrock:*::foundation-model/* + - Sid: GetSSMParameters + Effect: Allow + Action: + - ssm:GetParameter + Resource: + - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/app/asana/demo/agentcoregwy/* + - Sid: Identity + Effect: Allow + Action: + - bedrock-agentcore:GetResourceOauth2Token + - bedrock-agentcore:GetWorkloadAccessToken + - bedrock-agentcore:GetResourceApiKey + Resource: + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default/oauth2credentialprovider/* + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default/apikeycredentialprovider/* + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/* + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/default + - !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:token-vault/* + - Sid: SecretsManagerAccessPolicy + Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: + - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:asana_integration_demo_agent* + - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:AgentCoreAPIGatewayAPIKey* + - !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:bedrock-agentcore* + + # Runtime AgentCore Role + RuntimeAgentCoreRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: AssumeRolePolicy + Effect: Allow + Principal: + Service: bedrock-agentcore.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: !Ref AWS::AccountId + ArnLike: + aws:SourceArn: !Sub arn:aws:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:* + Policies: + - PolicyName: AsanaIntegrationDemoAgentCoreRuntimePolicy + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: LambdaInvoke + Effect: Allow + Action: + - lambda:InvokeFunction + Resource: + - !GetAtt AsanaIntegrationApiLambdaFunction.Arn + + # ===== SSM PARAMETERS SECTION ===== + GatewayAgentcoreIAMRoleParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/gateway_iam_role + Type: String + Value: !GetAtt GatewayAgentCoreRole.Arn + Description: agentcore IAM role to assume + Tags: + Application: AsanaIntegrationDemo + + RuntimeAgentcoreIAMRoleParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/runtime_iam_role + Type: String + Value: !GetAtt RuntimeAgentCoreRole.Arn + Description: agentcore IAM role to assume + Tags: + Application: AsanaIntegrationDemo + + LambdaArnParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/lambda_arn + Type: String + Value: !GetAtt AsanaIntegrationApiLambdaFunction.Arn + Description: ARN of the lambda that integrates with agentcore + Tags: + Application: AsanaIntegrationDemo + + APIGatewayURLParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/apigateway_url + Type: String + Value: !Sub "https://${AsanaIntegrationApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod" + Description: URL of the API Gateway for 3rd party integration + Tags: + Application: AsanaIntegrationDemo + + APIKeyParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /app/asana/demo/agentcoregwy/api_key + Type: String + Value: !Ref AsanaIntegrationApiKey + Description: API Key for Asana Integration Demo API Gateway + Tags: + Application: AsanaIntegrationDemo + +Outputs: + # API Gateway Outputs + AsanaIntegrationApiUrl: + Description: "API Gateway endpoint URL for Asana Integration function" + Value: !Sub "https://${AsanaIntegrationApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod" + Export: + Name: !Sub "${AWS::StackName}-AsanaIntegrationApiUrl" + + AsanaIntegrationApiLambdaFunctionArn: + Description: "Asana Integration Lambda Function ARN" + Value: !GetAtt AsanaIntegrationApiLambdaFunction.Arn + Export: + Name: !Sub "${AWS::StackName}-AsanaIntegrationApiLambdaFunctionArn" + + # IAM Role Outputs + GatewayAgentCoreRoleArn: + Description: "Gateway AgentCore IAM Role ARN" + Value: !GetAtt GatewayAgentCoreRole.Arn + Export: + Name: !Sub "${AWS::StackName}-GatewayAgentCoreRoleArn" + + RuntimeAgentCoreRoleArn: + Description: "Runtime AgentCore IAM Role ARN" + Value: !GetAtt RuntimeAgentCoreRole.Arn + Export: + Name: !Sub "${AWS::StackName}-RuntimeAgentCoreRoleArn" + + AsanaIntegrationApiKey: + Description: "API Key for Asana Integration Demo API Gateway" + Value: !Ref AsanaIntegrationApiKey + Export: + Name: !Sub "${AWS::StackName}-AsanaIntegrationApiKey" diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/prereq.sh b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/prereq.sh new file mode 100755 index 000000000..d15b45b7a --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/agentcore-components/prereq.sh @@ -0,0 +1,86 @@ +#!/bin/sh + +# Enable strict error handling +set -euo pipefail + +# ----- Config ----- +BUCKET_NAME=${1:-asanaintegrationdemo111} +INFRA_STACK_NAME=${2:-AsanaIntegrationStackInfra} +COGNITO_STACK_NAME=${3:-AsanaIntegrationStackCognito} +INFRA_TEMPLATE_FILE="infrastructure_all.yaml" +COGNITO_TEMPLATE_FILE="cognito.yaml" +REGION=$(aws configure get region 2>/dev/null || echo "us-east-1") + + +# Get AWS Account ID with proper error handling +echo "๐Ÿ” Getting AWS Account ID..." +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text 2>&1) +if [ $? -ne 0 ] || [ -z "$ACCOUNT_ID" ] || [ "$ACCOUNT_ID" = "None" ]; then + echo "โŒ Failed to get AWS Account ID. Please check your AWS credentials and network connectivity." + echo "Error: $ACCOUNT_ID" + exit 1 +fi + + +USER_POOL_NAME="AsanaIntegrationGWPool" +MACHINE_APP_CLIENT_NAME="AsanaIntegrationGWMachineClient" +WEB_APP_CLIENT_NAME="AsanaIntegrationGWWebClient" + +echo "Region: $REGION" +echo "Account ID: $ACCOUNT_ID" +# ----- 1. Create S3 bucket ----- +# ----- 4. Deploy CloudFormation ----- +deploy_stack() { + set +e + + local stack_name=$1 + local template_file=$2 + shift 2 + local params=("$@") + + echo "๐Ÿš€ Deploying CloudFormation stack: $stack_name" + + output=$(aws cloudformation deploy \ + --stack-name "$stack_name" \ + --template-file "$template_file" \ + --capabilities CAPABILITY_NAMED_IAM \ + --region "$REGION" 2>&1) + + exit_code=$? + + echo "$output" + + if [ $exit_code -ne 0 ]; then + if echo "$output" | grep -qi "No changes to deploy"; then + echo "โ„น๏ธ No updates for stack $stack_name, continuing..." + return 0 + else + echo "โŒ Error deploying stack $stack_name:" + echo "$output" + return $exit_code + fi + else + echo "โœ… Stack $stack_name deployed successfully." + return 0 + fi +} + +# ----- Run both stacks ----- +echo "๐Ÿ”ง Starting deployment of infrastructure stack" +deploy_stack "$INFRA_STACK_NAME" "$INFRA_TEMPLATE_FILE" +infra_exit_code=$? + +echo "โœ… Deployment complete for API stack." + + +echo "๐Ÿ”ง Starting deployment of infrastructure stack" +deploy_stack "$COGNITO_STACK_NAME" "$COGNITO_TEMPLATE_FILE" +cognito_exit_code=$? + +echo "โœ… Deployment complete for Cognito stack." + +echo "โœ… Deployment complete both prerequisite stacks." + +# cd ../../ + +# echo "โœ… Back to Bearer token injection home directory." diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/openapi-spec/openapi_simple.json b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/openapi-spec/openapi_simple.json new file mode 100644 index 000000000..9e7404a0c --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/prerequisites/openapi-spec/openapi_simple.json @@ -0,0 +1,76 @@ +[{ + "openapi": "3.0.0", + "info": { + "title": "Asana Single-Endpoint API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "REPLACE_WITH_API_GATEWAY_URL" + } + ], + "paths": { + "/asana": { + "post": { + "operationId": "asanaInvoke", + "description": "Catch-all Asana tool endpoint.", + "parameters": [ + { + "name": "X-Asana-Token", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Bearer token for Asana authentication (passed through from agent)" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tool_name": { + "type": "string", + "description": "Name of the Asana operation to execute" + }, + "name": { + "type": "string", + "description": "Task name" + }, + "notes": { + "type": "string", + "description": "Task notes" + }, + "project": { + "type": "string", + "description": "Project GID" + }, + "task_gid": { + "type": "string", + "description": "Task GID" + }, + "workspace": { + "type": "string", + "description": "Workspace GID (optional)" + } + }, + "required": [ + "tool_name" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} +] \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/requirements.txt b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/requirements.txt new file mode 100644 index 000000000..9c8e431b1 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/requirements.txt @@ -0,0 +1,9 @@ +strands-agents +strands-agents-tools +bedrock-agentcore +PyYAML +ddgs +bedrock_agentcore_starter_toolkit +requests>=2.32.0 # Security update +urllib3>=2.0.0 # Security update +cryptography>=41.0.0 # For secure token handling \ No newline at end of file diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/security_config.py b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/security_config.py new file mode 100644 index 000000000..5874e3fd9 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/security_config.py @@ -0,0 +1,236 @@ +""" +Security configuration and validation utilities for the bearer token injection demo. + +This module provides security-focused configuration and validation functions +to ensure secure handling of bearer tokens and API requests. +""" + +import re +import os +from typing import Dict, Any, Optional +from urllib.parse import urlparse + + +class SecurityConfig: + """Security configuration constants and validation methods.""" + + # Maximum request body size (1MB) + MAX_REQUEST_BODY_SIZE = 1024 * 1024 + + # Maximum token length + MAX_TOKEN_LENGTH = 2048 + + # Allowed tool name pattern (alphanumeric, hyphens, underscores) + TOOL_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + + # Required security headers + SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + } + + # API rate limits + DEFAULT_RATE_LIMIT = 100 + DEFAULT_BURST_LIMIT = 200 + DEFAULT_DAILY_QUOTA = 1000 + + @staticmethod + def validate_bearer_token(token: str) -> bool: + """ + Validate bearer token format and length. + + Args: + token: Bearer token to validate + + Returns: + True if token is valid, False otherwise + """ + if not token or not isinstance(token, str): + return False + + # Remove 'Bearer ' prefix if present + if token.startswith("Bearer "): + token = token[7:] + + # Check length + if len(token) > SecurityConfig.MAX_TOKEN_LENGTH: + return False + + # Basic format validation (base64-like characters) + if not re.match(r"^[A-Za-z0-9+/=_-]+$", token): + return False + + return True + + @staticmethod + def validate_tool_name(tool_name: str) -> bool: + """ + Validate tool name format. + + Args: + tool_name: Tool name to validate + + Returns: + True if tool name is valid, False otherwise + """ + if not tool_name or not isinstance(tool_name, str): + return False + + if len(tool_name) > 100: # Reasonable length limit + return False + + return bool(SecurityConfig.TOOL_NAME_PATTERN.match(tool_name)) + + @staticmethod + def validate_url(url: str, require_https: bool = True) -> bool: + """ + Validate URL format and security requirements. + + Args: + url: URL to validate + require_https: Whether to require HTTPS protocol + + Returns: + True if URL is valid, False otherwise + """ + if not url or not isinstance(url, str): + return False + + try: + parsed = urlparse(url) + + if require_https and parsed.scheme != "https": + return False + + if not parsed.netloc: + return False + + return True + except (ValueError, TypeError): + return False + + @staticmethod + def sanitize_log_data(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Sanitize data for logging by removing sensitive information. + + Args: + data: Data dictionary to sanitize + + Returns: + Sanitized data dictionary + """ + sensitive_keys = { + "token", + "password", + "secret", + "key", + "authorization", + "x-asana-token", + "bearer", + "api_key", + "access_token", + } + + sanitized = {} + for key, value in data.items(): + key_lower = key.lower() + if any(sensitive in key_lower for sensitive in sensitive_keys): + sanitized[key] = "[REDACTED]" + elif isinstance(value, dict): + sanitized[key] = SecurityConfig.sanitize_log_data(value) + else: + sanitized[key] = value + + return sanitized + + @staticmethod + def get_environment_config() -> Dict[str, str]: + """ + Get security-related environment configuration. + + Returns: + Dictionary of environment configuration + """ + return { + "DEMO_USERNAME": os.environ.get("DEMO_USERNAME", "testuser"), + "DEMO_SECRET_NAME": os.environ.get( + "DEMO_SECRET_NAME", "asana_integration_demo_agent" + ), + "ROLE_NAME": os.environ.get( + "ROLE_NAME", "AgentCoreGwyAsanaIntegrationRole" + ), + "POLICY_NAME": os.environ.get( + "POLICY_NAME", "AgentCoreGwyAsanaIntegrationPolicy" + ), + "MAX_REQUEST_SIZE": os.environ.get( + "MAX_REQUEST_SIZE", str(SecurityConfig.MAX_REQUEST_BODY_SIZE) + ), + "RATE_LIMIT": os.environ.get( + "RATE_LIMIT", str(SecurityConfig.DEFAULT_RATE_LIMIT) + ), + } + + +def validate_request_payload(payload: Dict[str, Any]) -> tuple[bool, Optional[str]]: + """ + Validate incoming request payload for security issues. + + Args: + payload: Request payload to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not isinstance(payload, dict): + return False, "Payload must be a dictionary" + + # Validate tool_name + tool_name = payload.get("tool_name") + if not SecurityConfig.validate_tool_name(tool_name): + return False, "Invalid tool_name format" + + # Validate string fields don't contain suspicious content + string_fields = ["name", "notes", "project", "task_gid", "workspace"] + for field in string_fields: + value = payload.get(field) + if value is not None: + if not isinstance(value, str): + return False, f"Field {field} must be a string" + + if len(value) > 1000: # Reasonable length limit + return False, f"Field {field} is too long" + + # Basic XSS prevention + if any(char in value for char in ["<", ">", '"', "'"]): + return False, f"Field {field} contains invalid characters" + + return True, None + + +def create_secure_response_headers() -> Dict[str, str]: + """ + Create secure HTTP response headers. + + Returns: + Dictionary of security headers + """ + headers = { + "Content-Type": "application/json", + "Access-Control-Allow-Headers": "Content-Type,X-Asana-Token,Authorization", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + } + + # Add security headers + headers.update(SecurityConfig.SECURITY_HEADERS) + + # Note: In production, restrict CORS origins + # For demo purposes, we allow all origins + headers["Access-Control-Allow-Origin"] = "*" + + return headers diff --git a/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/utils.py b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/utils.py new file mode 100644 index 000000000..7df06100a --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/07-bearer-token-injection/utils.py @@ -0,0 +1,217 @@ +""" +Utility functions for Asana Integration Demo AgentCore setup and management. + +This module provides helper functions for: +- AWS SSM parameter management +- Cognito user pool setup and authentication +- IAM role and policy creation for AgentCore +- DynamoDB operations +- AWS Secrets Manager operations +- Resource cleanup functions +""" + +import json +import os + +import boto3 +import requests +import time + +STS_CLIENT = boto3.client("sts") + +# Get AWS account details +REGION = boto3.session.Session().region_name + +# Configuration constants - use environment variables in production +USERNAME = os.environ.get("DEMO_USERNAME", "testuser") +SECRET_NAME = os.environ.get("DEMO_SECRET_NAME", "asana_integration_demo_agent") + +ROLE_NAME = os.environ.get("ROLE_NAME", "AgentCoreGwyAsanaIntegrationRole") +POLICY_NAME = os.environ.get("POLICY_NAME", "AgentCoreGwyAsanaIntegrationPolicy") + + +def load_api_spec(file_path: str) -> list: + """Load API specification from JSON file. + + Args: + file_path: Path to the JSON file containing API specification + + Returns: + List containing the API specification data + + Raises: + ValueError: If the JSON file doesn't contain a list or is invalid + FileNotFoundError: If the file doesn't exist + json.JSONDecodeError: If the file contains invalid JSON + """ + # Validate file path + if not file_path or not isinstance(file_path, str): + raise ValueError("file_path must be a non-empty string") + + # Check if file exists and is readable + if not os.path.exists(file_path): + raise FileNotFoundError(f"API specification file not found: {file_path}") + + if not os.access(file_path, os.R_OK): + raise PermissionError(f"Cannot read API specification file: {file_path}") + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise json.JSONDecodeError( + f"Invalid JSON in API specification file: {e}", e.doc, e.pos + ) + + if not isinstance(data, list): + raise ValueError("Expected a list in the JSON file") + + # Basic validation of API spec structure + if not data: + raise ValueError("API specification list cannot be empty") + + return data + + +def get_ssm_parameter(name: str, with_decryption: bool = True) -> str: + """Get parameter value from AWS Systems Manager Parameter Store. + + Args: + name: Parameter name to retrieve + with_decryption: Whether to decrypt secure string parameters + + Returns: + Parameter value as string + """ + ssm = boto3.client("ssm") + response = ssm.get_parameter(Name=name, WithDecryption=with_decryption) + return response["Parameter"]["Value"] + + +def put_ssm_parameter( + name: str, value: str, parameter_type: str = "String", with_encryption: bool = False +) -> None: + """Store parameter value in AWS Systems Manager Parameter Store. + + Args: + name: Parameter name to store + value: Parameter value to store + parameter_type: Type of parameter (String, StringList, SecureString) + with_encryption: Whether to encrypt the parameter as SecureString + """ + ssm = boto3.client("ssm") + + put_params = { + "Name": name, + "Value": value, + "Type": parameter_type, + "Overwrite": True, + } + + if with_encryption: + put_params["Type"] = "SecureString" + + ssm.put_parameter(**put_params) + + +def get_cognito_client_secret() -> str: + """Get Cognito user pool client secret. + + Returns: + Client secret string from Cognito user pool client + """ + client = boto3.client("cognito-idp") + response = client.describe_user_pool_client( + UserPoolId=get_ssm_parameter("/app/asana/demo/agentcoregwy/userpool_id"), + ClientId=get_ssm_parameter("/app/asana/demo/agentcoregwy/machine_client_id"), + ) + return response["UserPoolClient"]["ClientSecret"] + + +def fetch_access_token(client_id, client_secret, token_url): + """Fetch OAuth access token using client credentials flow. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + token_url: OAuth token endpoint URL + + Returns: + Access token string + + Raises: + ValueError: If required parameters are missing or invalid + requests.RequestException: If the HTTP request fails + KeyError: If the response doesn't contain an access token + """ + # Input validation + if not all([client_id, client_secret, token_url]): + raise ValueError("client_id, client_secret, and token_url are required") + + if not token_url.startswith(("https://", "http://")): + raise ValueError("token_url must be a valid HTTP/HTTPS URL") + + data = ( + f"grant_type=client_credentials&client_id={client_id}" + f"&client_secret={client_secret}" + ) + + try: + response = requests.post( + token_url, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + verify=True, # Ensure SSL verification is enabled + ) + response.raise_for_status() # Raise an exception for bad status codes + + response_data = response.json() + + if "access_token" not in response_data: + raise KeyError("Response does not contain 'access_token' field") + + return response_data["access_token"] + + except requests.exceptions.Timeout: + raise requests.RequestException("Request timed out while fetching access token") + except requests.exceptions.ConnectionError: + raise requests.RequestException("Connection error while fetching access token") + except requests.exceptions.HTTPError as e: + raise requests.RequestException(f"HTTP error while fetching access token: {e}") + except json.JSONDecodeError: + raise requests.RequestException("Invalid JSON response from token endpoint") + + +def delete_gateway(gateway_client, gateway_name): + """Delete AgentCore gateway and all its targets. + + Args: + gateway_client: Boto3 client for bedrock-agentcore-control + gateway_id: ID of the gateway to delete + """ + gateway_id = get_ssm_parameter("/app/asana/demo/agentcoregwy/gateway_id") + + print("Deleting all targets for gateway", gateway_id) + list_response = gateway_client.list_gateway_targets( + gatewayIdentifier=gateway_id, maxResults=100 + ) + for item in list_response["items"]: + target_id = item["targetId"] + print("Deleting target ", target_id) + gateway_client.delete_gateway_target( + gatewayIdentifier=gateway_id, targetId=target_id + ) + # wait for 30 secs + time.sleep(30) + + list_response = gateway_client.list_gateway_targets( + gatewayIdentifier=gateway_id, maxResults=100 + ) + if len(list_response["items"]) > 0: + print(f"{len(list_response['items'])} targets not deleted successfully)") + else: + print("All targets deleted successfully)") + + print("Deleting gateway ", gateway_id) + gateway_client.delete_gateway(gatewayIdentifier=gateway_id) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 94fd23f36..47f1254c1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -37,6 +37,7 @@ - Sunil Ramachandra - Sandeep Raveesh-Babu - chintanpatel-ai +- saurabh-et-al - Evandro Franco - greg-aws - Frank Dallezotte