diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/README.md b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/demo.py b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/demo.py new file mode 100755 index 000000000..9af54b59f --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/demo.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +"""CLI demo for AgentCore Gateway token exchange at request interceptor.""" + +import argparse +import base64 +import json +import subprocess +import sys +import time + +import bedrock_models +import requests +from strands import Agent +from strands.models import BedrockModel +from strands.tools.mcp.mcp_client import MCPClient +from mcp.client.streamable_http import streamablehttp_client + + +# -- ANSI styling ------------------------------------------------------------- + +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +UNDERLINE = "\033[4m" + +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +BLUE = "\033[34m" +MAGENTA = "\033[35m" +CYAN = "\033[36m" +WHITE = "\033[37m" + +BG_BLACK = "\033[40m" + +OK = f"{GREEN}{BOLD} OK {RESET}" +FAIL = f"{RED}{BOLD} FAIL {RESET}" + + +def header(text: str) -> None: + width = 72 + print() + print(f"{CYAN}{BOLD}{'=' * width}{RESET}") + print(f"{CYAN}{BOLD} {text}{RESET}") + print(f"{CYAN}{BOLD}{'=' * width}{RESET}") + print() + + +def section(text: str) -> None: + print(f"\n{BLUE}{BOLD}--- {text} ---{RESET}\n") + + +def kv(key: str, value: str, mask: bool = False) -> None: + display = value[:12] + "..." if mask and len(value) > 12 else value + print(f" {DIM}{key:<32}{RESET} {WHITE}{display}{RESET}") + + +def status(label: str, ok: bool) -> None: + tag = OK if ok else FAIL + print(f" [{tag}] {label}") + + +def step(n: int, text: str) -> None: + print(f"{MAGENTA}{BOLD}[Step {n}]{RESET} {text}") + + +def jwt_decode_payload(token: str) -> dict: + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + return json.loads(base64.urlsafe_b64decode(payload)) + + +def jwt_client_id(token: str) -> str: + claims = jwt_decode_payload(token) + return claims.get("client_id", "unknown") + + +# -- Token provider ------------------------------------------------------------ + + +class CognitoM2MTokenProvider: + """Acquires and caches a Cognito client_credentials token, refreshing on demand.""" + + def __init__( + self, + token_endpoint: str, + client_id: str, + client_secret: str, + resource_server_id: str, + verbose: bool = False, + ): + self._token_endpoint = token_endpoint + self._client_id = client_id + self._client_secret = client_secret + self._resource_server_id = resource_server_id + self._verbose = verbose + self._access_token: str | None = None + + def _fetch_token(self) -> str: + if self._verbose: + kv("Token endpoint", self._token_endpoint) + kv("Client ID", self._client_id) + kv("Grant type", "client_credentials") + kv( + "Scopes", + f"{self._resource_server_id}/read {self._resource_server_id}/write", + ) + + credentials = f"{self._client_id}:{self._client_secret}" + encoded = base64.b64encode(credentials.encode()).decode() + rs = self._resource_server_id + + resp = requests.post( + self._token_endpoint, + headers={ + "Authorization": f"Basic {encoded}", + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "client_credentials", + "scope": f"{rs}/read {rs}/write", + }, + timeout=10, + ) + + if resp.status_code != 200: + if self._verbose: + status("Token request", False) + print(f" {RED}{resp.status_code}: {resp.text}{RESET}") + raise RuntimeError( + f"Cognito token request failed: {resp.status_code} {resp.text}" + ) + + token_data = resp.json() + token = token_data["access_token"] + self._access_token = token + + if self._verbose: + status("Token request", True) + kv("Token type", token_data.get("token_type", "unknown")) + kv("Expires in", f"{token_data.get('expires_in', '?')}s") + kv("Access token", token, mask=True) + + section("JWT claims") + claims = jwt_decode_payload(token) + for k in ("iss", "client_id", "token_use", "scope", "exp"): + if k in claims: + val = claims[k] + if k == "exp": + val = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(val)) + kv(k, str(val)) + + return token + + @property + def token(self) -> str: + if self._access_token is None: + self._fetch_token() + return self._access_token # type: ignore[return-value] + + def refresh(self) -> str: + if self._verbose: + print(f" {YELLOW}Acquiring Cognito M2M token...{RESET}") + self._access_token = None + return self._fetch_token() + + +# -- Terraform output loader -------------------------------------------------- + + +def load_terraform_outputs(tf_dir: str) -> dict: + result = subprocess.run( + ["terraform", "output", "-json"], + cwd=tf_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"{RED}Failed to read terraform outputs:{RESET}") + print(result.stderr) + sys.exit(1) + raw = json.loads(result.stdout) + return {k: v["value"] for k, v in raw.items()} + + +# -- Demo steps ---------------------------------------------------------------- + + +def demo_config(cfg: dict) -> None: + section("Infrastructure") + kv("Gateway URL", cfg["gateway_url"]) + kv("Gateway ID", cfg["gateway_id"]) + kv("API Gateway URL", cfg["api_gateway_url"]) + kv("Interceptor Lambda", cfg["interceptor_lambda_arn"].split(":")[-1]) + + section("Cognito clients (two separate identities)") + kv("Gateway client (inbound)", cfg["cognito_gateway_client_id"]) + kv("Downstream client (API GW)", cfg["cognito_downstream_client_id"]) + kv("Resource Server", cfg["cognito_resource_server_id"]) + kv("Token Endpoint", cfg["cognito_token_endpoint"]) + + +def demo_direct_api_call(cfg: dict, access_token: str) -> None: + step(2, "Proving the gateway token is rejected by the API Gateway directly") + + url = f"{cfg['api_gateway_url']}/posts" + resp = requests.post( + url, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + json={"title": "Direct call", "body": "This should fail", "userId": 1}, + timeout=10, + ) + + rejected = resp.status_code == 401 + status( + f"API Gateway returned {resp.status_code} (expected 401)", + rejected, + ) + if rejected: + print( + f" {DIM}The gateway client token was correctly rejected by the API Gateway.{RESET}" + ) + else: + print(f" {YELLOW}Unexpected status. Response: {resp.text[:200]}{RESET}") + + +def demo_downstream_api_call(cfg: dict) -> None: + step(3, "Proving the downstream client token IS accepted by the API Gateway") + + downstream_provider = CognitoM2MTokenProvider( + token_endpoint=cfg["cognito_token_endpoint"], + client_id=cfg["cognito_downstream_client_id"], + client_secret=cfg["cognito_downstream_client_secret"], + resource_server_id=cfg["cognito_resource_server_id"], + # verbose=True, + ) + downstream_token = downstream_provider.refresh() + kv("Downstream client_id", jwt_client_id(downstream_token)) + + url = f"{cfg['api_gateway_url']}/posts" + resp = requests.post( + url, + headers={ + "Authorization": f"Bearer {downstream_token}", + "Content-Type": "application/json", + }, + json={ + "title": "Downstream direct call", + "body": "This should succeed", + "userId": 1, + }, + timeout=10, + ) + + accepted = resp.status_code in (200, 201) + status( + f"API Gateway returned {resp.status_code} (expected 200/201)", + accepted, + ) + if accepted: + print( + f" {DIM}The downstream client token was accepted by the API Gateway.{RESET}" + ) + try: + print(f" {GREEN}{json.dumps(resp.json(), indent=2)}{RESET}") + except (json.JSONDecodeError, ValueError): + print(f" {GREEN}{resp.text[:300]}{RESET}") + else: + print(f" {YELLOW}Unexpected status. Response: {resp.text[:200]}{RESET}") + + +def demo_gateway_tools(cfg: dict, token_provider: CognitoM2MTokenProvider) -> None: + step(4, "Connecting to AgentCore Gateway via MCP") + + gateway_url = cfg["gateway_url"] + + def create_transport(): + return streamablehttp_client( + gateway_url, + headers={"Authorization": f"Bearer {token_provider.token}"}, + ) + + client = MCPClient(create_transport) + + with client: + tools = client.list_tools_sync() + status("MCP handshake", True) + kv("Tools discovered", str(len(tools))) + + section("Available tools") + for i, tool in enumerate(tools): + name = tool.tool_name + desc = getattr(tool, "description", "") or "" + desc = desc[:60] + "..." if len(desc) > 60 else desc + print(f" {YELLOW}{i + 1}.{RESET} {BOLD}{name}{RESET} {DIM}{desc}{RESET}") + + step( + 5, + "Calling createPost through the gateway (interceptor exchanges token)", + ) + + if tools: + tool_name = tools[0].tool_name + arguments = { + "title": "Hello from the CLI demo", + "body": "This post was created through the AgentCore Gateway with token exchange.", + "userId": 1, + } + print(f" {DIM}Tool: {tool_name}{RESET}") + print(f" {DIM}Arguments: {json.dumps(arguments, indent=None)}{RESET}") + + result = client.call_tool_sync( + tool_use_id="demo-call-001", + name=tool_name, + arguments=arguments, + ) + + content_text = ( + result["content"][0]["text"] if result.get("content") else str(result) + ) + status("Tool invocation via gateway", True) + + section("Tool response") + try: + parsed = json.loads(content_text) + print(f" {GREEN}{json.dumps(parsed, indent=2)}{RESET}") + except (json.JSONDecodeError, TypeError): + print(f" {GREEN}{content_text[:500]}{RESET}") + + print() + print( + f" {CYAN}The interceptor exchanged the gateway-client token for a{RESET}" + ) + print( + f" {CYAN}downstream-client token before forwarding to the API Gateway.{RESET}" + ) + print( + f" {DIM}Gateway client: {cfg['cognito_gateway_client_id']}{RESET}" + ) + print( + f" {DIM}Downstream client: {cfg['cognito_downstream_client_id']}{RESET}" + ) + else: + print(f" {YELLOW}No tools available to invoke{RESET}") + + +def demo_agent(cfg: dict, prompt: str) -> None: + step(6, "Running Strands agent with M2M token acquisition") + + gateway_url = cfg["gateway_url"] + token_provider = CognitoM2MTokenProvider( + token_endpoint=cfg["cognito_token_endpoint"], + client_id=cfg["cognito_gateway_client_id"], + client_secret=cfg["cognito_gateway_client_secret"], + resource_server_id=cfg["cognito_resource_server_id"], + # verbose=True, + ) + + # -- 5a: attempt without a token to demonstrate the 401 ----------------- + section("Attempting gateway connection without a token") + resp = requests.post( + gateway_url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}, + timeout=10, + ) + is_401 = resp.status_code == 401 + status(f"Gateway returned {resp.status_code} (expected 401)", is_401) + if is_401: + print(f" {DIM}No token provided -- gateway rejected the request.{RESET}") + else: + print(f" {YELLOW}Unexpected: {resp.text[:200]}{RESET}") + + # -- 5b: acquire token via M2M and connect ------------------------------ + section("Acquiring M2M token via client_credentials flow") + access_token = token_provider.refresh() + + print() + print(f" {YELLOW}client_id = {jwt_client_id(access_token)}{RESET}") + print( + f" {DIM}This is the GATEWAY client. The API Gateway will NOT accept this token.{RESET}" + ) + print( + f" {DIM}The interceptor will exchange it for a DOWNSTREAM client token.{RESET}" + ) + + # -- 5c: connect and run agent ------------------------------------------ + section("Connecting agent to gateway") + + def create_transport(): + return streamablehttp_client( + gateway_url, + headers={"Authorization": f"Bearer {token_provider.token}"}, + ) + + client = MCPClient(create_transport) + modelId = bedrock_models.global_model_id( + bedrock_models.Models.ANTHROPIC_CLAUDE_SONNET_4_6 + ) + model = BedrockModel(model_id=modelId, temperature=0.7) + + with client: + tools = client.list_tools_sync() + status("MCP handshake with token", True) + + agent = Agent(model=model, tools=tools) + kv("Agent model", modelId) + kv("Agent tools", ", ".join(t.tool_name for t in tools)) + + section(f"Agent prompt: {WHITE}{prompt}{RESET}") + print(f" {DIM}Thinking...{RESET}") + + response = agent(prompt) + + section("Agent response") + print(f" {GREEN}{str(response)}{RESET}") + + +# -- Main --------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description="Demo: AgentCore Gateway token exchange at request interceptor" + ) + parser.add_argument( + "--tf-dir", + default="terraform", + help="Path to the terraform directory (default: terraform)", + ) + parser.add_argument( + "--skip-agent", + action="store_true", + help="Skip the Strands agent step", + ) + parser.add_argument( + "--prompt", + default="Create a post titled 'Token Exchange Works' with a body explaining that the interceptor successfully exchanged the token.", + help="Prompt to send to the Strands agent", + ) + args = parser.parse_args() + + header("AgentCore Gateway - Token Exchange at Request Interceptor") + + print(f"{DIM}Loading terraform outputs from {args.tf_dir}...{RESET}") + cfg = load_terraform_outputs(args.tf_dir) + demo_config(cfg) + + # Single token provider used across all steps + token_provider = CognitoM2MTokenProvider( + token_endpoint=cfg["cognito_token_endpoint"], + client_id=cfg["cognito_gateway_client_id"], + client_secret=cfg["cognito_gateway_client_secret"], + resource_server_id=cfg["cognito_resource_server_id"], + verbose=True, + ) + + step(1, "Acquiring inbound token using the GATEWAY client") + access_token = token_provider.refresh() + + print() + print(f" {YELLOW}client_id = {jwt_client_id(access_token)}{RESET}") + print( + f" {DIM}This is the GATEWAY client. The API Gateway will NOT accept this token.{RESET}" + ) + print( + f" {DIM}The interceptor will exchange it for a DOWNSTREAM client token.{RESET}" + ) + + print() + print(f"{CYAN}{BOLD}{'- ' * 36}{RESET}") + print(f"{CYAN} Two Cognito clients are in play:{RESET}") + print( + f"{CYAN} 1. Gateway client - authenticates the caller to AgentCore Gateway{RESET}" + ) + print( + f"{CYAN} 2. Downstream client - authenticates the call to the API Gateway{RESET}" + ) + print( + f"{CYAN} The interceptor Lambda exchanges (1) for (2) on every request.{RESET}" + ) + print(f"{CYAN}{BOLD}{'- ' * 36}{RESET}") + print() + + demo_direct_api_call(cfg, access_token) + demo_downstream_api_call(cfg) + demo_gateway_tools(cfg, token_provider) + + if not args.skip_agent: + demo_agent(cfg, args.prompt) + + header("Demo complete") + + +if __name__ == "__main__": + main() diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/pyproject.toml b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/pyproject.toml new file mode 100644 index 000000000..48020c470 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "14-token-exchange-at-request-interceptor" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "bedrock-models>=0.1.58", + "requests>=2.32.5", +] diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/.gitignore b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/.gitignore new file mode 100644 index 000000000..1afad68c5 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/.gitignore @@ -0,0 +1,6 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.backup +*.tfplan +.build/ diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/README.md b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/README.md new file mode 100644 index 000000000..e47dd5919 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/README.md @@ -0,0 +1,126 @@ +# AgentCore Gateway with Token Exchange at Request Interceptor - Terraform + +This Terraform configuration provisions the same infrastructure as the companion Jupyter notebook (`token-exchange-at-request-interceptor.ipynb`), enabling secure token exchange and identity propagation in multi-hop agent workflows using AgentCore Gateway. + +## Architecture + +1. **Client** initiates requests with Cognito OAuth2 tokens (client credentials flow) +2. **AgentCore Gateway** routes requests through an interceptor for token exchange +3. **Gateway Interceptor Lambda** validates the inbound token and exchanges it for a scoped downstream token via Cognito +4. **API Gateway (OpenAPI Target)** receives the processed request with the exchanged token +5. **Strands Agent** (not provisioned by Terraform) can connect to the gateway via streamable HTTP transport + +## Prerequisites + +- [Terraform](https://developer.hashicorp.com/terraform/install) >= 1.0 +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) configured with credentials +- AWS provider >= 5.0 (with `aws_bedrockagentcore_*` resource support) + +## File Structure + +``` +terraform/ +├── providers.tf # AWS, archive, null, random providers +├── variables.tf # region, name_prefix +├── data.tf # Account ID, region, random suffix for unique names +├── cognito.tf # User Pool, Domain, Resource Server, App Client +├── lambda.tf # Pre Token Generation + Gateway Interceptor Lambdas +├── apigateway.tf # REST API (OpenAPI), Authorizer, API Key, Usage Plan +├── agentcore.tf # Credential Provider, Gateway IAM Role, Gateway, Target +├── outputs.tf # Key outputs (IDs, ARNs, URLs) +└── lambda_src/ + ├── pre_token_generation/ + │ └── lambda_function.py + └── gateway_interceptor/ + └── lambda_function.py +``` + +## Resources Created + +| Resource | Terraform Resource | +|---|---| +| Cognito User Pool (Essentials tier) | `aws_cognito_user_pool.this` + `null_resource.configure_user_pool` | +| Cognito Resource Server (read/write scopes) | `aws_cognito_resource_server.this` | +| Cognito App Client (client_credentials) | `aws_cognito_user_pool_client.this` | +| Cognito User Pool Domain | `aws_cognito_user_pool_domain.this` | +| Pre Token Generation Lambda + IAM Role | `aws_lambda_function.pre_token_generation` | +| Gateway Interceptor Lambda + IAM Role | `aws_lambda_function.gateway_interceptor` | +| API Gateway REST API (OpenAPI import) | `aws_api_gateway_rest_api.this` | +| Cognito Authorizer | `aws_api_gateway_authorizer.cognito` | +| API Key + Usage Plan | `aws_api_gateway_api_key.this` + `aws_api_gateway_usage_plan.this` | +| AgentCore API Key Credential Provider | `aws_bedrockagentcore_api_key_credential_provider.this` | +| AgentCore Gateway (Custom JWT + Interceptor) | `aws_bedrockagentcore_gateway.this` | +| AgentCore Gateway Target (OpenAPI) | `aws_bedrockagentcore_gateway_target.this` | + +## Usage + +```bash +cd terraform +terraform init +terraform apply +``` + +To customize the deployment: + +```bash +terraform apply -var="region=us-west-2" -var="name_prefix=myproject" +``` + +## Variables + +| Name | Description | Default | +|---|---|---| +| `region` | AWS region | `us-east-1` | +| `name_prefix` | Prefix for resource names | `agentcore` | + +## Outputs + +| Name | Description | +|---|---| +| `cognito_user_pool_id` | Cognito User Pool ID | +| `cognito_client_id` | Cognito App Client ID | +| `cognito_client_secret` | Cognito App Client Secret (sensitive) | +| `cognito_token_endpoint` | Cognito OAuth2 token endpoint | +| `api_gateway_url` | API Gateway invoke URL | +| `gateway_id` | AgentCore Gateway ID | +| `gateway_url` | AgentCore Gateway URL | +| `gateway_target_id` | AgentCore Gateway Target ID | + +## Testing with Strands Agent + +After deploying, use the outputs to connect a Strands agent: + +```python +from strands import Agent +from strands.models import BedrockModel +from strands.tools.mcp.mcp_client import MCPClient +from mcp.client.streamable_http import streamablehttp_client + +# Use terraform output values +gateway_url = "" +access_token = "" + +client = MCPClient(lambda: streamablehttp_client( + gateway_url, + headers={"Authorization": f"Bearer {access_token}"} +)) + +model = BedrockModel(model_id="us.amazon.nova-pro-v1:0") + +with client: + tools = client.list_tools_sync() + agent = Agent(model=model, tools=tools) + response = agent("List all tools available to you") +``` + +## Cleanup + +```bash +terraform destroy +``` + +## Design Notes + +- A `random_id` suffix is used instead of timestamps to avoid resource recreation on every plan/apply. +- A `null_resource` with AWS CLI is used to upgrade the Cognito User Pool to Essentials tier and attach the V3_0 Pre Token Generation trigger, since the Terraform AWS provider does not natively support `UserPoolTier`. +- Native `aws_bedrockagentcore_*` Terraform resources are used for the gateway, target, and credential provider. diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/agentcore.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/agentcore.tf new file mode 100644 index 000000000..852a59b86 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/agentcore.tf @@ -0,0 +1,146 @@ +# ============================================================================= +# API Key Credential Provider (required by OpenAPI targets; actual auth is +# handled by the interceptor which injects the Cognito JWT) +# ============================================================================= +resource "aws_bedrockagentcore_api_key_credential_provider" "this" { + name = "api-key-provider-${local.suffix}" + api_key = "placeholder-not-used-for-auth" +} + +# ============================================================================= +# IAM Role for AgentCore Gateway +# ============================================================================= +resource "aws_iam_role" "gateway" { + name = "AgentCoreGatewayRole-${local.suffix}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "bedrock-agentcore.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "gateway_agentcore" { + role = aws_iam_role.gateway.name + policy_arn = "arn:aws:iam::aws:policy/BedrockAgentCoreFullAccess" +} + +resource "aws_iam_role_policy" "gateway_lambda_invoke" { + name = "LambdaInvokePolicy" + role = aws_iam_role.gateway.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["lambda:InvokeAsync", "lambda:InvokeFunction"] + Resource = "*" + }] + }) +} + +# ============================================================================= +# AgentCore Gateway +# ============================================================================= +resource "aws_bedrockagentcore_gateway" "this" { + name = "${var.name_prefix}-gateway-${local.suffix}" + description = "AgentCore Gateway with Cognito 2LO auth - ${local.suffix}" + role_arn = aws_iam_role.gateway.arn + + authorizer_type = "CUSTOM_JWT" + authorizer_configuration { + custom_jwt_authorizer { + discovery_url = "https://cognito-idp.${local.region}.amazonaws.com/${aws_cognito_user_pool.this.id}/.well-known/openid-configuration" + allowed_clients = [aws_cognito_user_pool_client.gateway.id] + allowed_scopes = [ + "${local.resource_server_id}/read", + "${local.resource_server_id}/write", + ] + } + } + + protocol_type = "MCP" + protocol_configuration { + mcp { + supported_versions = ["2025-03-26", "2025-06-18"] + } + } + + interceptor_configuration { + interception_points = ["REQUEST"] + + interceptor { + lambda { + arn = aws_lambda_function.gateway_interceptor.arn + } + } + + input_configuration { + pass_request_headers = true + } + } + + depends_on = [ + aws_iam_role_policy_attachment.gateway_agentcore, + aws_iam_role_policy.gateway_lambda_invoke, + ] +} + +# ============================================================================= +# AgentCore Gateway Target (OpenAPI — clean spec, no API Gateway extensions) +# ============================================================================= +resource "aws_bedrockagentcore_gateway_target" "this" { + name = "posts-api-target-${local.suffix}" + gateway_identifier = aws_bedrockagentcore_gateway.this.gateway_id + + credential_provider_configuration { + api_key { + provider_arn = aws_bedrockagentcore_api_key_credential_provider.this.credential_provider_arn + credential_parameter_name = "X-Api-Key" + credential_location = "HEADER" + } + } + + target_configuration { + mcp { + open_api_schema { + inline_payload { + payload = jsonencode(local.target_openapi_spec) + } + } + } + } +} + +# Clean OpenAPI spec for the AgentCore target — no x-amazon-apigateway-* +# extensions, just standard OpenAPI that AgentCore converts into MCP tools. +locals { + target_openapi_spec = { + openapi = "3.0.1" + info = { + title = "Posts API" + version = "1.0.0" + description = "Create and manage posts" + } + servers = [{ + url = local.api_gateway_url + description = "Posts API Gateway endpoint" + }] + components = { + schemas = local.schemas + } + paths = { + "/posts" = { + post = { + summary = "Create a new post" + operationId = "createPost" + requestBody = local.create_post_request_body + responses = local.create_post_responses + } + } + } + } +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/apigateway.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/apigateway.tf new file mode 100644 index 000000000..17d8684ec --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/apigateway.tf @@ -0,0 +1,139 @@ +# ============================================================================= +# API Gateway (REST API with Cognito authorizer) +# ============================================================================= + +resource "aws_api_gateway_rest_api" "this" { + name = "Posts API ${local.suffix}" + description = "Posts API with Cognito JWT authentication" + + body = jsonencode({ + openapi = "3.0.1" + info = { + title = "Posts API ${local.suffix}" + version = "1.0.0" + description = "Posts API authenticated via Cognito JWT" + } + components = { + securitySchemes = { + CognitoAuth = { + type = "apiKey" + name = "Authorization" + in = "header" + "x-amazon-apigateway-authtype" = "cognito_user_pools" + "x-amazon-apigateway-authorizer" = { + type = "cognito_user_pools" + providerARNs = [aws_cognito_user_pool.this.arn] + } + } + } + schemas = local.schemas + } + paths = { + "/posts" = { + post = { + summary = "Create a new post" + operationId = "createPost" + security = [{ CognitoAuth = [ + "${local.resource_server_id}/read", + "${local.resource_server_id}/write", + ] }] + requestBody = local.create_post_request_body + responses = local.create_post_responses + "x-amazon-apigateway-integration" = { + type = "mock" + requestTemplates = { + "application/json" = "{\"statusCode\": 201}" + } + responses = { + default = { + statusCode = "201" + responseTemplates = { + "application/json" = jsonencode({ + id = 42 + title = "$input.path('$.title')" + body = "$input.path('$.body')" + userId = "$input.path('$.userId')" + }) + } + } + } + } + } + } + } + }) + + depends_on = [aws_cognito_user_pool_domain.this] +} + +# --- Deployment --- +resource "aws_api_gateway_deployment" "this" { + rest_api_id = aws_api_gateway_rest_api.this.id + + triggers = { + redeployment = sha1(jsonencode(aws_api_gateway_rest_api.this.body)) + } + + lifecycle { + create_before_destroy = true + } +} + +# --- Stage --- +resource "aws_api_gateway_stage" "prod" { + deployment_id = aws_api_gateway_deployment.this.id + rest_api_id = aws_api_gateway_rest_api.this.id + stage_name = "prod" + description = "Production deployment - ${local.suffix}" +} + +# ============================================================================= +# Shared schema fragments (used by the AgentCore target OpenAPI spec) +# ============================================================================= +locals { + api_gateway_url = "https://${aws_api_gateway_rest_api.this.id}.execute-api.${local.region}.amazonaws.com/${aws_api_gateway_stage.prod.stage_name}" + + schemas = { + CreatePostRequest = { + type = "object" + required = ["title", "body"] + properties = { + title = { type = "string", description = "Title of the post" } + body = { type = "string", description = "Body text of the post" } + userId = { type = "integer", description = "ID of the authoring user" } + } + } + Post = { + type = "object" + properties = { + id = { type = "integer" } + title = { type = "string" } + body = { type = "string" } + userId = { type = "integer" } + } + } + } + + create_post_request_body = { + required = true + content = { + "application/json" = { + schema = { "$ref" = "#/components/schemas/CreatePostRequest" } + } + } + } + + create_post_responses = { + "201" = { + description = "Post created" + content = { + "application/json" = { + schema = { "$ref" = "#/components/schemas/Post" } + } + } + } + "401" = { + description = "Unauthorized - invalid or missing JWT" + } + } +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/cognito.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/cognito.tf new file mode 100644 index 000000000..3e4f4e160 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/cognito.tf @@ -0,0 +1,114 @@ +# ----------------------------------------------------------------------------- +# Cognito User Pool +# ----------------------------------------------------------------------------- +resource "aws_cognito_user_pool" "this" { + name = "${var.name_prefix}-pool-${local.suffix}" + + password_policy { + minimum_length = 8 + require_uppercase = false + require_lowercase = false + require_numbers = false + require_symbols = false + } +} + +# Upgrade to Essentials tier (required for V3_0 Pre Token Generation). +# Then attach the Pre Token Generation Lambda trigger. +# The aws_cognito_user_pool resource does not support UserPoolTier natively, +# and the V3_0 trigger requires Essentials tier, so both are done via CLI. +resource "null_resource" "configure_user_pool" { + depends_on = [ + aws_cognito_user_pool.this, + aws_lambda_function.pre_token_generation, + aws_lambda_permission.cognito_pre_token, + ] + + triggers = { + user_pool_id = aws_cognito_user_pool.this.id + lambda_arn = aws_lambda_function.pre_token_generation.arn + } + + provisioner "local-exec" { + command = <<-EOT + aws cognito-idp update-user-pool \ + --user-pool-id ${aws_cognito_user_pool.this.id} \ + --user-pool-tier ESSENTIALS \ + --region ${local.region} + + sleep 5 + + aws cognito-idp update-user-pool \ + --user-pool-id ${aws_cognito_user_pool.this.id} \ + --lambda-config '{"PreTokenGeneration":"${aws_lambda_function.pre_token_generation.arn}","PreTokenGenerationConfig":{"LambdaVersion":"V3_0","LambdaArn":"${aws_lambda_function.pre_token_generation.arn}"}}' \ + --region ${local.region} + EOT + } +} + +# ----------------------------------------------------------------------------- +# Cognito User Pool Domain +# ----------------------------------------------------------------------------- +resource "aws_cognito_user_pool_domain" "this" { + domain = local.cognito_domain + user_pool_id = aws_cognito_user_pool.this.id +} + +# ----------------------------------------------------------------------------- +# Cognito Resource Server +# ----------------------------------------------------------------------------- +resource "aws_cognito_resource_server" "this" { + identifier = local.resource_server_id + name = "AgentCore API ${local.suffix}" + user_pool_id = aws_cognito_user_pool.this.id + + scope { + scope_name = "read" + scope_description = "Read access to AgentCore Gateway" + } + + scope { + scope_name = "write" + scope_description = "Write access to AgentCore Gateway" + } +} + +# ----------------------------------------------------------------------------- +# Cognito App Client - Gateway (inbound auth to AgentCore Gateway) +# ----------------------------------------------------------------------------- +resource "aws_cognito_user_pool_client" "gateway" { + name = "${var.name_prefix}-gateway-client-${local.suffix}" + user_pool_id = aws_cognito_user_pool.this.id + + generate_secret = true + allowed_oauth_flows = ["client_credentials"] + allowed_oauth_flows_user_pool_client = true + supported_identity_providers = ["COGNITO"] + + allowed_oauth_scopes = [ + "${local.resource_server_id}/read", + "${local.resource_server_id}/write", + ] + + depends_on = [aws_cognito_resource_server.this] +} + +# ----------------------------------------------------------------------------- +# Cognito App Client - Downstream (used by interceptor for API Gateway auth) +# ----------------------------------------------------------------------------- +resource "aws_cognito_user_pool_client" "downstream" { + name = "${var.name_prefix}-downstream-client-${local.suffix}" + user_pool_id = aws_cognito_user_pool.this.id + + generate_secret = true + allowed_oauth_flows = ["client_credentials"] + allowed_oauth_flows_user_pool_client = true + supported_identity_providers = ["COGNITO"] + + allowed_oauth_scopes = [ + "${local.resource_server_id}/read", + "${local.resource_server_id}/write", + ] + + depends_on = [aws_cognito_resource_server.this] +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/data.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/data.tf new file mode 100644 index 000000000..904c6bb21 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/data.tf @@ -0,0 +1,14 @@ +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +resource "random_id" "suffix" { + byte_length = 4 +} + +locals { + account_id = data.aws_caller_identity.current.account_id + region = data.aws_region.current.name + suffix = random_id.suffix.hex + resource_server_id = "${var.name_prefix}-api-${local.suffix}" + cognito_domain = "${var.name_prefix}-${local.suffix}" +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda.tf new file mode 100644 index 000000000..e7c0b94fa --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda.tf @@ -0,0 +1,101 @@ +# ============================================================================= +# Pre Token Generation Lambda +# ============================================================================= + +# --- IAM Role --- +resource "aws_iam_role" "pre_token_lambda" { + name = "PreTokenLambdaRole-${local.suffix}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "pre_token_lambda_basic" { + role = aws_iam_role.pre_token_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# --- Lambda Package --- +data "archive_file" "pre_token_generation" { + type = "zip" + source_dir = "${path.module}/lambda_src/pre_token_generation" + output_path = "${path.module}/.build/pre_token_generation.zip" +} + +# --- Lambda Function --- +resource "aws_lambda_function" "pre_token_generation" { + function_name = "pre-token-generation-${local.suffix}" + description = "Pre Token Generation Lambda for Cognito User Pool" + runtime = "python3.13" + handler = "lambda_function.lambda_handler" + role = aws_iam_role.pre_token_lambda.arn + filename = data.archive_file.pre_token_generation.output_path + source_code_hash = data.archive_file.pre_token_generation.output_base64sha256 +} + +# --- Cognito Permission to Invoke --- +resource "aws_lambda_permission" "cognito_pre_token" { + statement_id = "cognito-trigger-permission" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.pre_token_generation.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.this.arn +} + +# ============================================================================= +# Gateway Interceptor Lambda +# ============================================================================= + +# --- IAM Role --- +resource "aws_iam_role" "interceptor_lambda" { + name = "InterceptorLambdaRole-${local.suffix}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "interceptor_lambda_basic" { + role = aws_iam_role.interceptor_lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# --- Lambda Package --- +data "archive_file" "gateway_interceptor" { + type = "zip" + source_dir = "${path.module}/lambda_src/gateway_interceptor" + output_path = "${path.module}/.build/gateway_interceptor.zip" +} + +# --- Lambda Function --- +resource "aws_lambda_function" "gateway_interceptor" { + function_name = "gateway-interceptor-${local.suffix}" + description = "Gateway Interceptor for AgentCore Gateway" + runtime = "python3.13" + handler = "lambda_function.lambda_handler" + role = aws_iam_role.interceptor_lambda.arn + filename = data.archive_file.gateway_interceptor.output_path + source_code_hash = data.archive_file.gateway_interceptor.output_base64sha256 + + environment { + variables = { + DOWNSTREAM_CLIENT_ID = aws_cognito_user_pool_client.downstream.id + DOWNSTREAM_CLIENT_SECRET = aws_cognito_user_pool_client.downstream.client_secret + COGNITO_DOMAIN = "${local.cognito_domain}.auth.${local.region}.amazoncognito.com" + RESOURCE_SERVER_ID = local.resource_server_id + } + } + + depends_on = [aws_cognito_user_pool_domain.this] +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/gateway_interceptor/lambda_function.py b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/gateway_interceptor/lambda_function.py new file mode 100644 index 000000000..74b0a887f --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/gateway_interceptor/lambda_function.py @@ -0,0 +1,92 @@ +import json +import logging +import os +import urllib3 +import base64 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event, context): + logger.info("Interceptor received event: %s", json.dumps(event, default=str)) + + mcp_data = event.get("mcp", {}) + gateway_request = mcp_data.get("gatewayRequest", {}) + headers = gateway_request.get("headers", {}) + body = gateway_request.get("body", {}) + + auth_header = headers.get("authorization", "") or headers.get("Authorization", "") + + # Exchange the inbound token for a downstream-scoped token. + # The inbound token was issued to the gateway client; the exchanged token + # is issued to the downstream client and is the only one accepted by the + # API Gateway Cognito authorizer. + downstream_token = "" + if auth_header: + try: + client_id = os.environ.get("DOWNSTREAM_CLIENT_ID") + client_secret = os.environ.get("DOWNSTREAM_CLIENT_SECRET") + cognito_domain = os.environ.get("COGNITO_DOMAIN") + resource_server_id = os.environ.get("RESOURCE_SERVER_ID") + + if not all([client_id, client_secret, cognito_domain, resource_server_id]): + logger.error("Missing required environment variables") + return _error_response("Interceptor misconfigured") + + http = urllib3.PoolManager() + token_url = f"https://{cognito_domain}/oauth2/token" + + auth_string = f"{client_id}:{client_secret}" + auth_b64 = base64.b64encode(auth_string.encode("ascii")).decode("ascii") + + req_headers = { + "Authorization": f"Basic {auth_b64}", + "Content-Type": "application/x-www-form-urlencoded", + } + + cognito_body = ( + f"grant_type=client_credentials" + f"&scope={resource_server_id}/read {resource_server_id}/write" + ) + + response = http.request( + "POST", token_url, headers=req_headers, body=cognito_body + ) + + if response.status == 200: + token_data = json.loads(response.data.decode("utf-8")) + if "access_token" in token_data: + downstream_token = f"Bearer {token_data['access_token']}" + logger.info(downstream_token) + logger.info("Exchanged inbound token for downstream token") + else: + logger.error("Token exchange failed with status %s", response.status) + except Exception as e: + logger.error("Token exchange error: %s", str(e)) + + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": downstream_token, + }, + "body": body, + } + }, + } + + +def _error_response(msg): + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": {"Content-Type": "application/json"}, + "body": {"error": msg}, + } + }, + } diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/pre_token_generation/lambda_function.py b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/pre_token_generation/lambda_function.py new file mode 100644 index 000000000..ceb28e117 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/lambda_src/pre_token_generation/lambda_function.py @@ -0,0 +1,37 @@ +import json +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def lambda_handler(event, context): + logger.info("Pre Token Generation Lambda triggered") + logger.info("Trigger Source: %s", event.get("triggerSource", "Unknown")) + + # V3_0 format for both ID and access token customization + event["response"]["claimsAndScopeOverrideDetails"] = { + "idTokenGeneration": { + "claimsToAddOrOverride": { + "custom:role": "agentcore_user", + "custom:permissions": "read,write", + "custom:tenant": "default", + "custom:api_access": "enabled", + }, + "claimsToSuppress": [], + }, + "accessTokenGeneration": { + "claimsToAddOrOverride": { + "custom:role": "agentcore_user", + "custom:permissions": "read,write", + "custom:tenant": "default", + "custom:api_access": "enabled", + }, + "claimsToSuppress": [], + "scopesToAdd": [], + "scopesToSuppress": [], + }, + } + + logger.info("Custom claims added to both ID and access tokens") + return event diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/outputs.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/outputs.tf new file mode 100644 index 000000000..8c340efb4 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/outputs.tf @@ -0,0 +1,93 @@ +# ----------------------------------------------------------------------------- +# Cognito +# ----------------------------------------------------------------------------- +output "cognito_user_pool_id" { + description = "Cognito User Pool ID" + value = aws_cognito_user_pool.this.id +} + +output "cognito_gateway_client_id" { + description = "Cognito App Client ID for AgentCore Gateway inbound auth" + value = aws_cognito_user_pool_client.gateway.id +} + +output "cognito_gateway_client_secret" { + description = "Cognito App Client Secret for AgentCore Gateway inbound auth" + value = aws_cognito_user_pool_client.gateway.client_secret + sensitive = true +} + +output "cognito_downstream_client_id" { + description = "Cognito App Client ID for downstream API Gateway auth" + value = aws_cognito_user_pool_client.downstream.id +} + +output "cognito_downstream_client_secret" { + description = "Cognito App Client Secret for downstream API Gateway auth" + value = aws_cognito_user_pool_client.downstream.client_secret + sensitive = true +} + +output "cognito_resource_server_id" { + description = "Cognito Resource Server identifier" + value = local.resource_server_id +} + +output "cognito_domain" { + description = "Cognito User Pool domain" + value = local.cognito_domain +} + +output "cognito_token_endpoint" { + description = "Cognito OAuth2 token endpoint" + value = "https://${local.cognito_domain}.auth.${local.region}.amazoncognito.com/oauth2/token" +} + +# ----------------------------------------------------------------------------- +# API Gateway +# ----------------------------------------------------------------------------- +output "api_gateway_id" { + description = "REST API Gateway ID" + value = aws_api_gateway_rest_api.this.id +} + +output "api_gateway_url" { + description = "API Gateway invoke URL" + value = local.api_gateway_url +} + +# ----------------------------------------------------------------------------- +# Lambda +# ----------------------------------------------------------------------------- +output "interceptor_lambda_arn" { + description = "Gateway Interceptor Lambda ARN" + value = aws_lambda_function.gateway_interceptor.arn +} + +output "pre_token_lambda_arn" { + description = "Pre Token Generation Lambda ARN" + value = aws_lambda_function.pre_token_generation.arn +} + +# ----------------------------------------------------------------------------- +# AgentCore Gateway +# ----------------------------------------------------------------------------- +output "gateway_id" { + description = "AgentCore Gateway ID" + value = aws_bedrockagentcore_gateway.this.gateway_id +} + +output "gateway_url" { + description = "AgentCore Gateway URL" + value = aws_bedrockagentcore_gateway.this.gateway_url +} + +output "gateway_arn" { + description = "AgentCore Gateway ARN" + value = aws_bedrockagentcore_gateway.this.gateway_arn +} + +output "gateway_target_id" { + description = "AgentCore Gateway Target ID" + value = aws_bedrockagentcore_gateway_target.this.target_id +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/providers.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/providers.tf new file mode 100644 index 000000000..90242e716 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/providers.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + archive = { + source = "hashicorp/archive" + version = ">= 2.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } +} + +provider "aws" { + region = var.region +} diff --git a/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/variables.tf b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/variables.tf new file mode 100644 index 000000000..fc7f81a43 --- /dev/null +++ b/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/terraform/variables.tf @@ -0,0 +1,11 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "name_prefix" { + description = "Prefix for resource names" + type = string + default = "agentcore" +}