1
0
mirror of synced 2026-05-22 14:43:35 +00:00
This commit is contained in:
Chris Lamont-Smith
2026-05-02 00:00:05 +08:00
committed by GitHub
parent 27b7022a8c
commit 43b7a14354
37 changed files with 8859 additions and 0 deletions
@@ -0,0 +1,22 @@
# Python
.venv/
__pycache__/
*.pyc
*.egg-info/
/build
# CDK
cdk.out/
cdk.context.json
# Environment / secrets
.env
# Dependencies lock (regenerated by uv sync)
uv.lock
# OS
.DS_Store
# IDE / tools
.claude/
@@ -0,0 +1,511 @@
# Private IdP Connectivity: PingFederate with AgentCore Identity via VPC Lattice
> **Disclaimer:** This sample is for experimental and educational purposes only. It is not intended for production use.
This sample demonstrates how to connect **Amazon Bedrock AgentCore Identity** to a privately hosted **PingFederate** Identity Provider (IdP) using **Amazon VPC Lattice**, eliminating the need for the IdP to be exposed to the public internet.
The sample covers two AgentCore Identity patterns:
1. **Outbound OAuth** — the agent runtime acquires OAuth tokens from the private PingFederate IdP via AgentCore Identity and VPC Lattice (no public network path to the IdP).
2. **Gateway inbound auth** — the agent presents its PingFederate token to an AgentCore Gateway configured with CUSTOM_JWT authorization, proving that the gateway can validate JWTs from a private IdP via VPC Lattice.
The token is a security credential and is never exposed to an LLM or returned to the caller — only non-sensitive metadata (client_id, scope, expiry) and the gateway tools/list response are returned to confirm success.
## Deployment Modes
This sample supports two VPC Lattice deployment modes:
| Mode | Deploy Command | Description |
|------|---------------|-------------|
| **AgentCore-managed** (default) | `./deploy_sample.sh` | AgentCore Identity creates and manages VPC Lattice resources automatically. You provide VPC and subnet IDs. Simpler setup. |
| **Self-managed** | `./deploy_sample.sh --self-managed-lattice` | You deploy VPC Lattice resources (resource gateway + configuration) via CDK. You manage the Lattice lifecycle. More control. |
## Architecture
```
┌──────────────────────┐
│ AgentCore Runtime │
│ (agent) │
└──────┬───────┬───────┘
1. Get token │ │ 2. Call tools/list
(outbound OAuth) │ │ (Bearer token)
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ AgentCore Identity │ │ AgentCore Gateway │
│ (credential provider │ │ (CUSTOM_JWT auth with │
│ with privateEndpoint) │ │ privateEndpoint) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
│ VPC Lattice │ VPC Lattice
│ (private connectivity) │ (JWKS validation)
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Your VPC (private subnets) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Internal ALB (HTTPS:443) │ │
│ │ + Private Hosted Zone (ping.example.com → ALB) │ │
│ └──────────────────────┬──────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ PingFederate (ECS Fargate) │ │
│ │ OAuth2/OIDC Identity Provider │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Why Private IdP Connectivity?
Many enterprises run their Identity Providers (IdPs) in private networks with no public internet exposure. AgentCore Identity needs to communicate with the IdP to perform OAuth2 flows (token acquisition, discovery, JWKS retrieval).
**VPC Lattice** solves this by providing private, secure, unidirectional network connectivity from AgentCore Identity to your IdP without requiring:
- A public-facing load balancer
- VPN or Direct Connect
- VPC peering
- NAT gateways for the IdP
## Key Concepts
### Private Hosted Zone
A critical requirement: the VPC Lattice resource gateway resolves the discovery URL domain **from within your VPC**. You must create a Route 53 **private hosted zone** that maps your certificate domain (e.g., `ping.example.com`) to the internal ALB. This CDK sample creates the private hosted zone automatically.
Without the private hosted zone, AgentCore Identity will fail with "HTTP request failed against private endpoint" because the domain cannot be resolved within the VPC.
### VPC Lattice Resource Gateway
A **Resource Gateway** is a set of Elastic Network Interfaces (ENIs) deployed in the private subnets of the VPC where your IdP runs. It serves as the ingress point for Lattice traffic into the VPC.
### VPC Lattice Resource Configuration
A **Resource Configuration** describes the target resource (your PingFederate ALB) so that Lattice knows where to route traffic. It specifies the DNS name, port, and protocol. The `rcfg-xxx` ID is what you provide to AgentCore Identity in self-managed mode.
### AgentCore Identity Private Endpoint
The `privateEndpoint` attribute on the OAuth2 credential provider tells AgentCore Identity to reach the IdP through VPC Lattice instead of the public internet:
- **AgentCore-managed mode**: provide `managedVpcResource` with VPC ID and subnet IDs — AgentCore creates the Lattice resources for you.
- **Self-managed mode**: provide `selfManagedLatticeResource` with the `rcfg-xxx` resource configuration ID from your CDK-deployed Lattice resources.
## What Gets Deployed
### CDK Stacks
| Stack | Resources | Always Deployed? |
|-------|-----------|-----------------|
| **PrivateIdpVpcStack** | VPC with public/private subnets (2 AZs, 1 NAT gateway) | Yes |
| **PrivateIdpPingFederateStack** | ECR repo, ECS Fargate service, internal ALB, Route 53 private hosted zone, Lambda custom resource (configures PingFederate OAuth/OIDC), Secrets Manager | Yes |
| **PrivateIdpGatewayInfraStack** | MCP Echo Lambda (gateway target), IAM role for the gateway | Yes |
| **PrivateIdpLatticeStack** | VPC Lattice resource gateway + resource configuration | Only with `--self-managed-lattice` |
### Manual Steps (after CDK deployment)
1. **Credential provider** — created via AWS CLI with `privateEndpoint` configuration
2. **Gateway** — created via AWS CLI with CUSTOM_JWT auth and `privateEndpoint` for JWKS validation
3. **Gateway target** — MCP Echo Lambda added as a gateway target via AWS CLI
4. **Runtime** — deployed via [agentcore-cli](https://github.com/aws/agentcore-cli) using the code in `agent/`
## Prerequisites
- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) v2.27+
- [AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) v2 (`npm install -g aws-cdk`)
- [uv](https://docs.astral.sh/uv/) for Python dependency management
- [Python 3.12+](https://www.python.org/downloads/)
- [Docker](https://docs.docker.com/get-docker/) (for building/pushing the PingFederate container image)
- [agentcore-cli](https://github.com/aws/agentcore-cli) (`npm install -g @aws/agentcore`)
- [Node.js 20+](https://nodejs.org/) (for agentcore-cli and CDK)
- **PingFederate DevOps credentials** — [sign up here](https://devops.pingidentity.com/get-started/devopsRegistration/)
- **A publicly trusted ACM certificate** — AgentCore Identity requires a publicly trusted TLS certificate to connect via VPC Lattice. The ALB itself remains internal.
- AWS account with permissions to create VPC, ECS, VPC Lattice, Route 53, AgentCore Identity, and AgentCore Runtime resources
## Setup
### 1. Configure
```bash
cd 01-tutorials/03-AgentCore-identity/08-IDP-examples/PingFederate
```
Create a `.env` file:
```bash
cat <<EOF > .env
PING_IDENTITY_DEVOPS_USER=your-email@example.com
PING_IDENTITY_DEVOPS_KEY=your-devops-key
CERTIFICATE_ARN=arn:aws:acm:us-east-1:123456789012:certificate/abc-123
PING_DOMAIN=ping.example.com
EOF
```
| Variable | Description |
|----------|-------------|
| `PING_IDENTITY_DEVOPS_USER` | PingFederate DevOps email |
| `PING_IDENTITY_DEVOPS_KEY` | PingFederate DevOps key |
| `CERTIFICATE_ARN` | ARN of a **publicly trusted** ACM certificate for your domain |
| `PING_DOMAIN` | Domain name matching the certificate (e.g., `ping.example.com`) |
The deployment region is determined by `AWS_REGION` in your shell environment (or your AWS CLI default region). If `AWS_REGION` is not set, it defaults to `us-east-1`.
### 2. Deploy infrastructure
```bash
./deploy_sample.sh # AgentCore-managed Lattice (default)
./deploy_sample.sh --self-managed-lattice # Self-managed Lattice
```
The deployment takes approximately 1520 minutes. The script will:
1. Validate prerequisites
2. Install Python dependencies
3. Bootstrap CDK
4. Deploy all stacks
5. Output the AWS CLI commands to create the credential provider, gateway, and gateway target
### 3. Create the AgentCore Identity credential provider
After deployment, the script outputs the exact AWS CLI command. Choose based on your deployment mode:
**AgentCore-managed mode** (default):
```bash
aws bedrock-agentcore-control create-oauth2-credential-provider \
--name "ping-private-idp" \
--credential-provider-vendor "CustomOauth2" \
--oauth2-provider-config-input '{
"customOauth2ProviderConfig": {
"oauthDiscovery": {
"discoveryUrl": "https://ping.example.com/.well-known/openid-configuration"
},
"clientId": "agentcore-client",
"clientSecret": "agentcore-test-secret-12345",
"privateEndpoint": {
"managedVpcResource": {
"vpcIdentifier": "vpc-xxx",
"subnetIds": ["subnet-xxx", "subnet-yyy"],
"endpointIpAddressType": "IPV4"
}
}
}
}'
```
**Self-managed mode** (`--self-managed-lattice`):
```bash
aws bedrock-agentcore-control create-oauth2-credential-provider \
--name "ping-private-idp" \
--credential-provider-vendor "CustomOauth2" \
--oauth2-provider-config-input '{
"customOauth2ProviderConfig": {
"oauthDiscovery": {
"discoveryUrl": "https://ping.example.com/.well-known/openid-configuration"
},
"clientId": "agentcore-client",
"clientSecret": "agentcore-test-secret-12345",
"privateEndpoint": {
"selfManagedLatticeResource": {
"resourceConfigurationIdentifier": "rcfg-xxx"
}
}
}
}'
```
### 4. Verify the credential provider
Wait ~3 minutes for the credential provider to become READY:
```bash
aws bedrock-agentcore-control get-oauth2-credential-provider \
--name "ping-private-idp" \
--query '{name: name, status: status}'
```
### 5. Create the AgentCore Gateway
The gateway uses CUSTOM_JWT inbound auth with PingFederate as the token issuer. The `privateEndpoint` tells the gateway to validate JWTs by reaching PingFederate's JWKS endpoint via VPC Lattice (private connectivity).
The deploy script outputs the exact command with your stack values pre-filled. The command uses the IAM role and VPC configuration from the `PrivateIdpGatewayInfraStack`:
```bash
aws bedrock-agentcore-control create-gateway \
--name "PingGateway" \
--protocol-type "MCP" \
--role-arn "GATEWAY_ROLE_ARN" \
--authorizer-type "CUSTOM_JWT" \
--authorizer-configuration '{
"customJWTAuthorizer": {
"discoveryUrl": "https://ping.example.com/.well-known/openid-configuration",
"allowedClients": ["agentcore-client"],
"privateEndpoint": {
"managedVpcResource": {
"vpcIdentifier": "vpc-xxx",
"subnetIds": ["subnet-xxx", "subnet-yyy"],
"endpointIpAddressType": "IPV4"
}
}
}
}' \
--exception-level "DEBUG"
```
Wait ~23 minutes for the gateway to become READY:
```bash
aws bedrock-agentcore-control list-gateways \
--query 'items[?name==`PingGateway`].{id:gatewayId,status:status,url:gatewayUrl}'
```
### 6. Add the MCP Echo Lambda target
Once the gateway is READY, add the Lambda target. Replace `GATEWAY_ID` with the `gatewayId` from step 5:
```bash
aws bedrock-agentcore-control create-gateway-target \
--gateway-identifier GATEWAY_ID \
--name "McpEchoTarget" \
--target-configuration '{
"mcp": {
"lambda": {
"lambdaArn": "MCP_ECHO_LAMBDA_ARN",
"toolSchema": {
"inlinePayload": [
{
"name": "get_time",
"description": "Get the current UTC time",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "echo",
"description": "Echo a message back",
"inputSchema": {
"type": "object",
"properties": { "message": { "type": "string", "description": "Message to echo" } },
"required": ["message"]
}
}
]
}
}
}
}' \
--credential-provider-configurations '[{"credentialProviderType": "GATEWAY_IAM_ROLE"}]'
```
The deploy script outputs this command with the actual Lambda ARN pre-filled.
### 7. Deploy the runtime
The `agent/` directory contains a complete [agentcore-cli](https://github.com/aws/agentcore-cli) project — no scaffolding required. The deploy script automatically configures `aws-targets.json` with your account ID and region.
Before deploying, configure the gateway URL so the agent knows where to send authenticated requests.
Open `agent/private-idp-ping-agent/agentcore/agentcore.json` and add `GATEWAY_URL` to the `envVars` array
in the runtime definition:
```json
"envVars": [
{
"name": "GATEWAY_URL",
"value": "https://YOUR-GATEWAY-ID.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp"
}
]
```
Replace `YOUR-GATEWAY-ID` with the `gatewayId` from step 5. The full URL follows the pattern
`https://<gatewayId>.gateway.bedrock-agentcore.<region>.amazonaws.com/mcp`.
Then deploy:
```bash
cd agent/private-idp-ping-agent
agentcore deploy -y
```
> **Note:** You do **not** need to run `agentcore create`. The project structure and CDK config are already committed. `agentcore deploy` resolves your account and region from your configured AWS credentials.
### 8. Test the runtime
```bash
agentcore invoke --prompt "test"
```
Expected output:
```json
{
"success": true,
"claims": {
"scope": "openid",
"client_id": "agentcore-client",
"iss": "https://ping.example.com",
"iat": 1234567890,
"exp": 1234575090
},
"gateway": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "McpEchoTarget___echo",
"description": "Echo a message back"
},
{
"name": "McpEchoTarget___get_time",
"description": "Get the current UTC time"
}
]
}
}
}
```
The runtime:
1. Acquires an OAuth token from PingFederate via AgentCore Identity (outbound OAuth over VPC Lattice)
2. Presents the token to AgentCore Gateway as a Bearer token (inbound JWT auth)
3. The gateway validates the JWT by fetching PingFederate's JWKS over VPC Lattice (private connectivity)
4. Returns the tools/list response from the MCP Echo Lambda target
## Cleanup
### 1. Delete the gateway and credential provider
```bash
# Delete the gateway (this also deletes its targets)
aws bedrock-agentcore-control delete-gateway --gateway-identifier GATEWAY_ID
# Delete the credential provider
aws bedrock-agentcore-control delete-oauth2-credential-provider \
--name "ping-private-idp"
```
### 2. Delete the runtime
```bash
cd agent/private-idp-ping-agent
agentcore destroy -y
cd ../..
```
### 3. Destroy CDK stacks
```bash
./cleanup_sample.sh
```
The cleanup script deletes stacks in order: PrivateIdpLatticeStack → PrivateIdpGatewayInfraStack → PrivateIdpPingFederateStack → PrivateIdpVpcStack.
> **Note:** VPC Lattice ENIs (both self-managed and AgentCore-managed) can take up to 8 hours to be released by AWS. If PrivateIdpVpcStack deletion fails, wait and retry with `uv run cdk destroy PrivateIdpVpcStack --force`.
## Runtime Project Structure
```
agent/private-idp-ping-agent/
├── agentcore/
│ ├── agentcore.json # Runtime config + credential provider declaration
│ ├── aws-targets.json # Deployment target (empty — resolved from credentials)
│ ├── .gitignore
│ └── cdk/ # CDK infrastructure (committed, ready to deploy)
└── app/
└── private-idp-ping-agent/
├── main.py # Runtime with @requires_access_token
└── pyproject.toml # Python dependencies
```
The runtime uses:
- **`@requires_access_token`** decorator from `bedrock_agentcore.identity` to obtain OAuth tokens via the credential provider
- **`BedrockAgentCoreApp`** from `bedrock_agentcore.runtime` for the runtime lifecycle
- The **`GATEWAY_URL`** environment variable to call the AgentCore Gateway with the acquired token
No LLM or agent framework is required — this sample focuses purely on proving private IdP connectivity. The token is handled securely within the decorated function and never exposed beyond it.
> **Note:** The credential provider and gateway are created manually via AWS CLI because the agentcore-cli does not yet support `privateEndpoint` parameters. The `agentcore.json` declares the credential provider name for the runtime to reference.
## How It Works
```python
from bedrock_agentcore.identity import requires_access_token
from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
CREDENTIAL_PROVIDER_NAME = "ping-private-idp"
GATEWAY_URL = os.environ.get("GATEWAY_URL", "")
@requires_access_token(
provider_name=CREDENTIAL_PROVIDER_NAME,
scopes=["openid"],
auth_flow="M2M",
)
def fetch_token_from_private_idp(*, access_token: str) -> dict:
# The decorator handles:
# 1. Obtaining a workload identity token for this runtime
# 2. Exchanging it for an OAuth access token via the credential provider
# 3. The credential provider reaches PingFederate over VPC Lattice
# 4. Injecting the resulting access_token into this function
# Use the token to call AgentCore Gateway (inbound JWT auth)
if GATEWAY_URL:
gateway_result = call_gateway(access_token)
...
```
The `@requires_access_token` decorator abstracts the entire token acquisition flow. Your code simply declares which credential provider and scopes it needs — the SDK handles workload identity, the OAuth exchange, and private network routing via VPC Lattice.
The gateway call demonstrates inbound auth — the same PingFederate token is presented as a `Bearer` token to the gateway, which validates it by fetching PingFederate's JWKS over VPC Lattice.
## PingFederate Configuration
During deployment, a Lambda custom resource (`lambda/configure_pingfed/index.py`) running inside the VPC configures PingFederate via the Admin API:
- **RSA Signing Key** for JWT token signing
- **JWT Access Token Manager** using RS256
- **OAuth Authorization Server** with scopes: `openid`, `profile`, `email`
- **OIDC Policy** with standard claims
- **OAuth Client** (`agentcore-client`) configured for client credentials grant
- **Server settings** with the correct base URL for OIDC discovery
The client ID (`agentcore-client`) and secret (`agentcore-test-secret-12345`) are defined in `lambda/configure_pingfed/index.py`. For production use, rotate these values and store them securely.
## Cost Considerations
This sample creates resources that incur AWS charges:
| Resource | Approximate Cost |
|----------|-----------------|
| NAT Gateway | ~$32/month + data transfer |
| ECS Fargate (2 vCPU, 4 GB) | ~$70/month |
| Application Load Balancer | ~$16/month + LCU |
| VPC Lattice (self-managed only) | Based on data processed |
| EFS | Based on storage used |
**Run `./cleanup_sample.sh` immediately after testing to avoid ongoing charges.**
## Troubleshooting
### "HTTP request failed against private endpoint"
This typically means the discovery URL domain cannot be resolved within the VPC. Verify:
1. The private hosted zone exists and is associated with the VPC
2. The A record in the private zone points to the internal ALB
3. The domain in the discovery URL matches the private hosted zone name
The CDK stack creates the private hosted zone automatically. If you're adapting this for an existing IdP, ensure you have a private hosted zone mapping the IdP's domain to its internal endpoint.
### VPC Stack deletion fails
VPC Lattice ENIs can take up to 8 hours to release. Wait and retry:
```bash
uv run cdk destroy PrivateIdpVpcStack --force
```
To check ENI status:
```bash
VPC_ID=$(aws cloudformation describe-stacks --stack-name PrivateIdpVpcStack \
--query 'Stacks[0].Outputs[?OutputKey==`VpcId`].OutputValue' --output text)
aws ec2 describe-network-interfaces --filters Name=vpc-id,Values=$VPC_ID
```
## Note
PingFederate is not an AWS service. Please refer to PingIdentity documentation for costs and licensing. The PingFederate container image is pulled from Docker Hub under the PingIdentity DevOps program.
@@ -0,0 +1,141 @@
# AgentCore Project
This project contains configuration and infrastructure for an Amazon Bedrock AgentCore application.
The `agentcore/` directory is a declarative model of the project. The `agentcore/cdk/` subdirectory uses the
`@aws/agentcore-cdk` L3 constructs to deploy the configuration to AWS.
## Mental Model
The project uses a **flat resource model**. Agents, memories, credentials, gateways, evaluators, and policies are
independent top-level arrays in `agentcore.json`. There is no binding between resources in the schema — each resource is
provisioned independently. Agents discover memories and credentials at runtime via environment variables or SDK calls.
Tags defined in `agentcore.json` flow through to deployed CloudFormation resources.
## Critical Invariants
1. **Schema-First Authority:** The `.json` files are the source of truth. Do not modify agent behavior by editing
generated CDK code in `cdk/`.
2. **Resource Identity:** The `name` field determines the CloudFormation Logical ID.
- **Renaming** a resource will **destroy and recreate** it.
- **Modifying** other fields will update the resource **in-place**.
3. **Schema Validation:** If your JSON conforms to the types in `.llm-context/`, it will deploy successfully. Run
`agentcore validate` to check.
4. **Resource Removal:** Use `agentcore remove` to remove resources. Run `agentcore deploy` after removal to tear down
deployed infrastructure.
## Directory Structure
```
myProject/
├── AGENTS.md # This file — AI coding assistant context
├── agentcore/
│ ├── agentcore.json # Main project config (AgentCoreProjectSpec)
│ ├── aws-targets.json # Deployment targets (account + region)
│ ├── .env.local # Secrets — API keys (gitignored)
│ ├── .llm-context/ # TypeScript type definitions for AI assistants
│ │ ├── README.md # Guide to using schema files
│ │ ├── agentcore.ts # AgentCoreProjectSpec types
│ │ ├── aws-targets.ts # AWS deployment target types
│ │ └── mcp.ts # Gateway and MCP tool types
│ └── cdk/ # AWS CDK project (@aws/agentcore-cdk L3 constructs)
├── app/ # Agent application code
└── evaluators/ # Custom evaluator code (if any)
```
## Schema Reference
The `agentcore/.llm-context/` directory contains TypeScript type definitions optimized for AI coding assistants. Each
file maps to a JSON config file and includes validation constraints as comments (`@regex`, `@min`, `@max`).
| JSON Config | Schema File | Root Type |
| --- | --- | --- |
| `agentcore/agentcore.json` | `agentcore/.llm-context/agentcore.ts` | `AgentCoreProjectSpec` |
| `agentcore/agentcore.json` (gateways) | `agentcore/.llm-context/mcp.ts` | `AgentCoreMcpSpec` |
| `agentcore/aws-targets.json` | `agentcore/.llm-context/aws-targets.ts` | `AwsDeploymentTarget[]` |
### Key Types
- **AgentCoreProjectSpec**: Root config with `runtimes`, `memories`, `credentials`, `agentCoreGateways`, `evaluators`, `onlineEvalConfigs`, `policyEngines` arrays
- **AgentEnvSpec**: Agent configuration (build type, entrypoint, code location, runtime version, network mode)
- **Memory**: Memory resource with strategies (SEMANTIC, SUMMARIZATION, USER_PREFERENCE, EPISODIC) and expiry
- **Credential**: API key or OAuth credential provider
- **AgentCoreGateway**: MCP gateway with targets (Lambda, MCP server, OpenAPI, Smithy, API Gateway)
- **Evaluator**: LLM-as-a-Judge or code-based evaluator
- **OnlineEvalConfig**: Continuous evaluation pipeline bound to an agent
### Common Enum Values
- **BuildType**: `'CodeZip'` | `'Container'`
- **NetworkMode**: `'PUBLIC'` | `'VPC'`
- **RuntimeVersion**: `'PYTHON_3_10'` | `'PYTHON_3_11'` | `'PYTHON_3_12'` | `'PYTHON_3_13'` | `'PYTHON_3_14'` | `'NODE_18'` | `'NODE_20'` | `'NODE_22'`
- **MemoryStrategyType**: `'SEMANTIC'` | `'SUMMARIZATION'` | `'USER_PREFERENCE'` | `'EPISODIC'`
- **GatewayTargetType**: `'lambda'` | `'mcpServer'` | `'openApiSchema'` | `'smithyModel'` | `'apiGateway'` | `'lambdaFunctionArn'`
- **ModelProvider**: `'Bedrock'` | `'Gemini'` | `'OpenAI'` | `'Anthropic'`
### Build Types
- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime.
- **Container**: Docker image built in CodeBuild (ARM64), pushed to a per-agent ECR repository. Requires a `Dockerfile`
in the agent's `codeLocation` directory. For local development (`agentcore dev`), the container is built and run
locally with volume-mounted hot-reload.
### Supported Frameworks (for template agents)
- **Strands** — Bedrock, Anthropic, OpenAI, Gemini
- **LangChain/LangGraph** — Bedrock, Anthropic, OpenAI, Gemini
- **GoogleADK** — Gemini
- **OpenAI Agents** — OpenAI
- **Autogen** — Bedrock, Anthropic, OpenAI, Gemini
### Protocols
- **HTTP** — Standard HTTP agent endpoint
- **MCP** — Model Context Protocol server
- **A2A** — Agent-to-Agent protocol (Google A2A)
## Deployment
Deployments are orchestrated through the CLI:
```bash
agentcore deploy # Synthesizes CDK and deploys to AWS
agentcore status # Shows deployment status
```
Alternatively, deploy directly via CDK:
```bash
cd agentcore/cdk
npm install
npx cdk synth
npx cdk deploy
```
## Editing Schemas
When modifying JSON config files:
1. Read the corresponding `agentcore/.llm-context/*.ts` file for type definitions
2. Check validation constraint comments (`@regex`, `@min`, `@max`)
3. Use exact enum values as string literals
4. Use CloudFormation-safe names (alphanumeric, start with letter)
5. Run `agentcore validate` to verify changes
## CLI Commands
| Command | Description |
| --- | --- |
| `agentcore create` | Create a new project |
| `agentcore add <resource>` | Add agent, memory, credential, gateway, evaluator, policy |
| `agentcore remove <resource>` | Remove a resource |
| `agentcore dev` | Run agent locally with hot-reload |
| `agentcore deploy` | Deploy to AWS |
| `agentcore status` | Show deployment status |
| `agentcore invoke` | Invoke agent (local or deployed) |
| `agentcore logs` | View agent logs |
| `agentcore traces` | View agent traces |
| `agentcore eval` | Run evaluations against an agent |
| `agentcore package` | Package agent artifacts |
| `agentcore validate` | Validate configuration |
| `agentcore pause` / `resume` | Pause or resume a deployed agent |
@@ -0,0 +1,104 @@
# AgentCore Project
This project was created with the [AgentCore CLI](https://github.com/aws/agentcore-cli).
## Project Structure
```
my-project/
├── AGENTS.md # AI coding assistant context
├── agentcore/
│ ├── agentcore.json # Project config (agents, memories, credentials, gateways, evaluators)
│ ├── aws-targets.json # Deployment targets (account + region)
│ ├── .env.local # Secrets — API keys (gitignored)
│ ├── .llm-context/ # TypeScript type definitions for AI assistants
│ │ ├── agentcore.ts # AgentCoreProjectSpec types
│ │ ├── aws-targets.ts # Deployment target types
│ │ └── mcp.ts # Gateway and MCP tool types
│ └── cdk/ # CDK infrastructure (@aws/agentcore-cdk)
├── app/ # Agent application code
└── evaluators/ # Custom evaluator code (if any)
```
## Getting Started
### Prerequisites
- **Node.js** 20.x or later
- **Python 3.10+** and **uv** for Python agents ([install uv](https://docs.astral.sh/uv/getting-started/installation/))
- **AWS credentials** configured (`aws configure` or environment variables)
- **Docker** (only for Container build agents)
### Development
Run your agent locally:
```bash
agentcore dev
```
### Deployment
Deploy to AWS:
```bash
agentcore deploy
```
## Commands
| Command | Description |
| --- | --- |
| `agentcore create` | Create a new AgentCore project |
| `agentcore add` | Add resources (agent, memory, credential, gateway, evaluator, policy) |
| `agentcore remove` | Remove resources |
| `agentcore dev` | Run agent locally with hot-reload |
| `agentcore deploy` | Deploy to AWS via CDK |
| `agentcore status` | Show deployment status |
| `agentcore invoke` | Invoke agent (local or deployed) |
| `agentcore logs` | View agent logs |
| `agentcore traces` | View agent traces |
| `agentcore eval` | Run evaluations |
| `agentcore package` | Package agent artifacts |
| `agentcore validate` | Validate configuration |
| `agentcore pause` | Pause a deployed agent |
| `agentcore resume` | Resume a paused agent |
| `agentcore fetch` | Fetch remote resource definitions |
| `agentcore import` | Import existing resources |
| `agentcore update` | Check for CLI updates |
## Configuration
Edit the JSON files in `agentcore/` to configure your project. See `agentcore/.llm-context/` for type definitions and validation constraints.
The project uses a **flat resource model** — agents, memories, credentials, gateways, evaluators, and policies are top-level arrays in `agentcore.json`. Resources are independent; agents discover memories and credentials at runtime via environment variables or SDK calls.
## Resources
| Resource | Purpose |
| --- | --- |
| Agent (runtime) | HTTP, MCP, or A2A agent deployed to AgentCore Runtime |
| Memory | Persistent context storage with configurable strategies |
| Credential | API key or OAuth credential providers |
| Gateway | MCP gateway that routes tool calls to targets |
| Gateway Target | Tool implementation (Lambda, MCP server, OpenAPI, Smithy, API Gateway) |
| Evaluator | Custom LLM-as-a-Judge or code-based evaluation |
| Online Eval Config | Continuous evaluation pipeline for deployed agents |
| Policy | Cedar authorization policies for gateway tools |
### Agent Types
- **Template agents**: Created from framework templates (Strands, LangChain/LangGraph, GoogleADK, OpenAI Agents, Autogen)
- **BYO agents**: Bring your own code with `agentcore add agent --type byo`
- **Import agents**: Import existing Bedrock agents with `agentcore import`
### Build Types
- **CodeZip**: Python source packaged as a zip and deployed directly to AgentCore Runtime
- **Container**: Docker image built via CodeBuild (ARM64), pushed to ECR, and deployed to AgentCore Runtime
## Documentation
- [AgentCore CLI](https://github.com/aws/agentcore-cli)
- [AgentCore CDK Constructs](https://github.com/aws/agentcore-l3-cdk-constructs)
- [Amazon Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/)
@@ -0,0 +1,12 @@
# Secrets (local environment files are never committed)
.env.local
# CDK Build Artifacts
cdk/cdk.out/
cdk/node_modules/
# CLI Internals
.cli/
# Ephemeral Staging
.cache/
@@ -0,0 +1,16 @@
# LLM Context Files
**DO NOT EDIT THESE FILES** - They are read-only reference for AI coding assistants.
## Files
| File | JSON Config | Purpose |
| ---------------- | ------------------ | ----------------------------------------- |
| `agentcore.ts` | `agentcore.json` | Project, agent, memory, credential config |
| `mcp.ts` | `agentcore.json` | Gateways, targets, MCP runtime tools |
| `aws-targets.ts` | `aws-targets.json` | Deployment targets (account + region) |
## Usage
When editing schema JSON files, reference the corresponding `.ts` file here for type definitions and validation
constraints (marked with `@regex`, `@min`, `@max`).
@@ -0,0 +1,96 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* READ-ONLY LLM CONTEXT - Do not edit this file.
*
* JSON File: agentcore/agentcore.json
* Purpose: Top-level project configuration with flat resource model
*/
// ─────────────────────────────────────────────────────────────────────────────
// ROOT SCHEMA: AgentCoreProjectSpec
// ─────────────────────────────────────────────────────────────────────────────
interface AgentCoreProjectSpec {
name: string; // @regex ^[A-Za-z][A-Za-z0-9]{0,22}$ @max 23 - project name
version: number; // Schema version (integer)
managedBy: 'CDK'; // Enum — infrastructure manager. Default: "CDK"
tags?: Record<string, string>;
runtimes: AgentEnvSpec[]; // Unique by name
memories: Memory[]; // Unique by name
credentials: Credential[]; // Unique by name
}
// ─────────────────────────────────────────────────────────────────────────────
// ENUMS
// ─────────────────────────────────────────────────────────────────────────────
type BuildType = 'CodeZip' | 'Container';
type PythonRuntime = 'PYTHON_3_10' | 'PYTHON_3_11' | 'PYTHON_3_12' | 'PYTHON_3_13' | 'PYTHON_3_14';
type NodeRuntime = 'NODE_18' | 'NODE_20' | 'NODE_22';
type RuntimeVersion = PythonRuntime | NodeRuntime;
type NetworkMode = 'PUBLIC' | 'VPC';
interface NetworkConfig {
subnets: string[]; // subnet-xxx IDs
securityGroups: string[]; // sg-xxx IDs
}
type MemoryStrategyType = 'SEMANTIC' | 'SUMMARIZATION' | 'USER_PREFERENCE' | 'EPISODIC';
type ModelProvider = 'Bedrock' | 'Gemini' | 'OpenAI' | 'Anthropic';
// ─────────────────────────────────────────────────────────────────────────────
// AGENT
// ─────────────────────────────────────────────────────────────────────────────
type ProtocolMode = 'HTTP' | 'MCP' | 'A2A' | 'AGUI';
interface AgentEnvSpec {
name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48
build: BuildType;
entrypoint: string; // @regex ^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$ e.g. "main.py:handler" or "index.ts"
codeLocation: string; // Directory path
dockerfile?: string; // Custom Dockerfile name for Container builds (default: 'Dockerfile'). Must be a filename, not a path.
runtimeVersion?: RuntimeVersion;
envVars?: EnvVar[];
networkMode?: NetworkMode; // default 'PUBLIC'
networkConfig?: NetworkConfig; // Required when networkMode is 'VPC'
instrumentation?: Instrumentation; // OTel settings
protocol?: ProtocolMode; // default 'HTTP'
tags?: Record<string, string>;
}
interface Instrumentation {
enableOtel: boolean; // default true - wrap entrypoint with opentelemetry-instrument
}
interface EnvVar {
name: string; // @regex ^[A-Za-z_][A-Za-z0-9_]*$ @max 255
value: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// MEMORY
// ─────────────────────────────────────────────────────────────────────────────
interface Memory {
name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48
eventExpiryDuration: number; // @min 3 @max 365 (days)
strategies: MemoryStrategy[]; // @min 1, unique by type
tags?: Record<string, string>;
}
interface MemoryStrategy {
type: MemoryStrategyType;
name?: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48
description?: string;
namespaces?: string[];
reflectionNamespaces?: string[]; // EPISODIC only: namespaces for cross-episode reflections
}
// ─────────────────────────────────────────────────────────────────────────────
// CREDENTIAL
// ─────────────────────────────────────────────────────────────────────────────
interface Credential {
authorizerType: 'ApiKeyCredentialProvider' | 'OAuthCredentialProvider';
name: string; // @regex ^[a-zA-Z0-9\-_]+$ @min 1 @max 128
}
@@ -0,0 +1,45 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* READ-ONLY LLM CONTEXT - Do not edit this file.
*
* JSON File: agentcore/aws-targets.json
* Purpose: AWS deployment targets for AgentCore resources
*/
// ─────────────────────────────────────────────────────────────────────────────
// ROOT SCHEMA: AwsDeploymentTargets (array)
// ─────────────────────────────────────────────────────────────────────────────
// The JSON file contains an array of deployment targets.
// Target names must be unique within the array.
type AwsDeploymentTargets = AwsDeploymentTarget[];
interface AwsDeploymentTarget {
name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_-]*$ @max 64 - unique identifier
description?: string; // @max 256
account: string; // @regex ^[0-9]{12}$ - AWS account ID (exactly 12 digits)
region: AgentCoreRegion;
}
// ─────────────────────────────────────────────────────────────────────────────
// SUPPORTED REGIONS
// https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-regions.html
// ─────────────────────────────────────────────────────────────────────────────
type AgentCoreRegion =
| 'ap-northeast-1'
| 'ap-northeast-2'
| 'ap-south-1'
| 'ap-southeast-1'
| 'ap-southeast-2'
| 'ca-central-1'
| 'eu-central-1'
| 'eu-north-1'
| 'eu-west-1'
| 'eu-west-2'
| 'eu-west-3'
| 'sa-east-1'
| 'us-east-1'
| 'us-east-2'
| 'us-west-2'
| 'us-gov-west-1';
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.agentcore.aws.dev/v1/agentcore.json",
"name": "PrivateIdpPingAgent",
"version": 1,
"managedBy": "CDK",
"tags": {
"agentcore:created-by": "agentcore-cli",
"agentcore:project-name": "PrivateIdpPingAgent"
},
"runtimes": [
{
"name": "PrivateIdpPingAgent",
"build": "CodeZip",
"entrypoint": "main.py",
"codeLocation": "app/private-idp-ping-agent/",
"runtimeVersion": "PYTHON_3_12",
"envVars": [],
"networkMode": "PUBLIC",
"protocol": "HTTP"
}
],
"memories": [],
"credentials": [
{
"authorizerType": "OAuthCredentialProvider",
"name": "ping-private-idp",
"discoveryUrl": "https://example.com/.well-known/openid-configuration",
"scopes": [
"openid"
],
"vendor": "CustomOauth2",
"usage": "outbound"
}
],
"evaluators": [],
"onlineEvalConfigs": [],
"agentCoreGateways": [],
"policyEngines": []
}
@@ -0,0 +1 @@
[{"name": "default", "account": "837460776723", "region": "us-east-1"}]
@@ -0,0 +1,9 @@
# Build output
dist/
# Dependencies
node_modules/
# CDK asset staging directory
.cdk.staging
cdk.out
@@ -0,0 +1,6 @@
*.ts
!*.d.ts
# CDK asset staging directory
.cdk.staging
cdk.out
@@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"printWidth": 120,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"arrowParens": "avoid"
}
@@ -0,0 +1,26 @@
# AgentCore CDK Project
This CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs.
## Structure
- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target.
- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct.
- `test/cdk.test.ts` — Unit tests for stack synthesis.
## Useful commands
- `npm run build` compile TypeScript to JavaScript
- `npm run test` run unit tests
- `npx cdk synth` emit the synthesized CloudFormation template
- `npx cdk deploy` deploy this stack to your default AWS account/region
- `npx cdk diff` compare deployed stack with current state
## Usage
You typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment:
```bash
agentcore deploy # synthesizes and deploys via CDK
agentcore status # checks deployment status
```
@@ -0,0 +1,91 @@
#!/usr/bin/env node
import { AgentCoreStack } from '../lib/cdk-stack';
import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk';
import { App, type Environment } from 'aws-cdk-lib';
import * as path from 'path';
import * as fs from 'fs';
function toEnvironment(target: AwsDeploymentTarget): Environment {
return {
account: target.account,
region: target.region,
};
}
function sanitize(name: string): string {
return name.replace(/_/g, '-');
}
function toStackName(projectName: string, targetName: string): string {
return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`;
}
async function main() {
// Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/.
const configRoot = path.resolve(process.cwd(), '..');
const configIO = new ConfigIO({ baseDir: configRoot });
const spec = await configIO.readProjectSpec();
const targets = await configIO.readAWSDeploymentTargets();
// Extract MCP configuration from project spec.
// Gateway fields are stored in agentcore.json but may not yet be on the
// AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them
// dynamically and cast the resulting object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const specAny = spec as any;
const mcpSpec = specAny.agentCoreGateways?.length
? {
agentCoreGateways: specAny.agentCoreGateways,
mcpRuntimeTools: specAny.mcpRuntimeTools,
unassignedTargets: specAny.unassignedTargets,
}
: undefined;
// Read deployed state for credential ARNs (populated by pre-deploy identity setup)
let deployedState: Record<string, unknown> | undefined;
try {
deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8'));
} catch {
// Deployed state may not exist on first deploy
}
if (targets.length === 0) {
throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');
}
const app = new App();
for (const target of targets) {
const env = toEnvironment(target);
const stackName = toStackName(spec.name, target.name);
// Extract credentials from deployed state for this target
const targetState = (deployedState as Record<string, unknown>)?.targets as
| Record<string, Record<string, unknown>>
| undefined;
const targetResources = targetState?.[target.name]?.resources as Record<string, unknown> | undefined;
const credentials = targetResources?.credentials as
| Record<string, { credentialProviderArn: string; clientSecretArn?: string }>
| undefined;
new AgentCoreStack(app, stackName, {
spec,
mcpSpec,
credentials,
env,
description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,
tags: {
'agentcore:project-name': spec.name,
'agentcore:target-name': target.name,
},
});
}
app.synth();
}
main().catch((error: unknown) => {
console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error);
process.exitCode = 1;
});
@@ -0,0 +1,88 @@
{
"app": "node dist/bin/cdk.js",
"watch": {
"include": ["**"],
"exclude": ["README.md", "cdk*.json", "tsconfig.json", "package*.json", "yarn.lock", "node_modules", "dist", "test"]
},
"context": {
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws", "aws-cn", "aws-us-gov"],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/core:explicitStackTags": true,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true,
"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true,
"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true
}
}
@@ -0,0 +1,9 @@
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],
};
@@ -0,0 +1,30 @@
{
"name": "agentcore-cdk-app",
"version": "0.1.0",
"bin": {
"cdk": "dist/bin/cdk.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "npm run build && cdk",
"clean": "rm -rf dist",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^24.10.1",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"aws-cdk": "2.1100.1",
"prettier": "^3.4.2",
"typescript": "~5.9.3"
},
"dependencies": {
"@aws/agentcore-cdk": "^0.1.0-alpha.19",
"aws-cdk-lib": "^2.248.0",
"constructs": "^10.0.0"
}
}
@@ -0,0 +1,28 @@
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { AgentCoreStack } from '../lib/cdk-stack';
test('AgentCoreStack synthesizes with empty spec', () => {
const app = new cdk.App();
const stack = new AgentCoreStack(app, 'TestStack', {
spec: {
name: 'testproject',
version: 1,
managedBy: 'CDK' as const,
runtimes: [],
memories: [],
credentials: [],
evaluators: [],
onlineEvalConfigs: [],
policyEngines: [],
agentCoreGateways: [],
mcpRuntimeTools: [],
unassignedTargets: [],
configBundles: [],
},
});
const template = Template.fromStack(stack);
template.hasOutput('StackNameOutput', {
Description: 'Name of the CloudFormation Stack',
});
});
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["es2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": true,
"skipLibCheck": true,
"typeRoots": ["./node_modules/@types"],
"rootDir": ".",
"outDir": "dist"
},
"include": ["bin/**/*", "lib/**/*", "test/**/*"],
"exclude": ["node_modules", "cdk.out", "dist"]
}
@@ -0,0 +1,41 @@
# Environment variables
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
@@ -0,0 +1,100 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""AgentCore runtime demonstrating outbound OAuth token acquisition from a private PingFederate IdP.
This sample proves private IdP connectivity: AgentCore Identity acquires an OAuth token
from a PingFederate instance running inside a VPC (reached via VPC Lattice), without
exposing the identity provider to the public internet.
The token is a security credential — it is never passed to an LLM or returned to the caller.
Only non-sensitive metadata (client_id, scope, expiry) is returned to confirm success.
"""
import base64
import json
import os
import urllib.request
from bedrock_agentcore.identity import requires_access_token
from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
log = app.logger
CREDENTIAL_PROVIDER_NAME = os.environ.get("CREDENTIAL_PROVIDER_NAME", "ping-private-idp")
GATEWAY_URL = os.environ.get("GATEWAY_URL", "")
@requires_access_token(
provider_name=CREDENTIAL_PROVIDER_NAME,
scopes=["openid"],
auth_flow="M2M",
)
def fetch_token_from_private_idp(*, access_token: str) -> dict:
"""Acquire an OAuth token from the private PingFederate IdP via AgentCore Identity.
The @requires_access_token decorator handles:
1. Obtaining a workload identity token for this runtime
2. Exchanging it for an OAuth access token via the credential provider
3. The credential provider reaches PingFederate over VPC Lattice (private connectivity)
4. Injecting the resulting access_token into this function
We return only non-sensitive metadata to prove the flow works.
In a real application, you would use the token to call a downstream API.
"""
result = {"success": True}
parts = access_token.split(".")
if len(parts) == 3:
payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=="))
result["claims"] = payload
else:
result["token_type"] = "opaque"
if GATEWAY_URL:
gateway_result = call_gateway(access_token)
result["gateway"] = gateway_result
return result
def call_gateway(access_token: str) -> dict:
"""Call AgentCore Gateway's tools/list with the PingFederate token as a Bearer token."""
body = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {},
}).encode()
req = urllib.request.Request(GATEWAY_URL, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {access_token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except Exception as e:
return {"error": str(e)}
@app.entrypoint
async def invoke(payload, context):
"""Entrypoint for the AgentCore runtime.
Demonstrates acquiring an OAuth token from a private PingFederate IdP.
The token itself is never exposed — only metadata is returned.
"""
log.info("Acquiring OAuth token from private PingFederate IdP...")
try:
token_info = fetch_token_from_private_idp()
log.info("Token acquired successfully: client_id=%s", token_info.get("client_id"))
return json.dumps(token_info, indent=2)
except Exception as e:
log.error("Failed to acquire token: %s", e)
return json.dumps({"success": False, "error": str(e)})
if __name__ == "__main__":
app.run()
@@ -0,0 +1,17 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "private-idp-ping-agent"
version = "0.1.0"
description = "AgentCore runtime demonstrating outbound OAuth with a private PingFederate IdP via VPC Lattice"
requires-python = ">=3.10"
dependencies = [
"aws-opentelemetry-distro",
"bedrock-agentcore >= 1.0.3",
"botocore[crt] >= 1.35.0",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
@@ -0,0 +1,61 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""CDK application entry point."""
import aws_cdk as cdk
from config import CdkConfig
from stacks.lattice_stack import LatticeStack
from stacks.gateway_infra_stack import GatewayInfraStack
from stacks.ping_federate_stack import PingFederateStack
from stacks.vpc_stack import VpcStack
app = cdk.App()
config = CdkConfig(
aws_account=app.node.try_get_context("aws_account") or None,
)
env = cdk.Environment(
account=config.aws_account,
region=config.aws_region,
)
# Stack 1: VPC (separate stack for clean deletion — Lattice ENIs can take 8 hours to release)
vpc_stack = VpcStack(
app,
"PrivateIdpVpcStack",
env=env,
)
# Stack 2: PingFederate IdP (ECS Fargate, internal ALB, public ACM cert)
ping_stack = PingFederateStack(
app,
"PrivateIdpPingFederateStack",
vpc=vpc_stack.vpc,
config=config,
env=env,
)
ping_stack.add_dependency(vpc_stack)
# Stack 3: Gateway infrastructure (MCP Echo Lambda + IAM role)
gateway_infra_stack = GatewayInfraStack(
app,
"PrivateIdpGatewayInfraStack",
env=env,
)
# Stack 4: VPC Lattice (optional — only with --self-managed-lattice flag)
if config.deploy_lattice:
lattice_stack = LatticeStack(
app,
"PrivateIdpLatticeStack",
vpc=vpc_stack.vpc,
alb=ping_stack.alb,
alb_listener=ping_stack.alb_listener,
suffix=config.suffix,
env=env,
)
lattice_stack.add_dependency(ping_stack)
app.synth()
@@ -0,0 +1,6 @@
{
"app": "python3 app.py",
"context": {
"@aws-cdk/core:bootstrapQualifier": "pingidp"
}
}
@@ -0,0 +1,101 @@
#!/bin/bash
set -e
echo "=========================================="
echo "Cleaning up: PingFederate + VPC Lattice + AgentCore Identity"
echo "=========================================="
echo ""
echo "This will destroy ALL resources created by the sample."
echo ""
echo "Cleanup order:"
echo " 1. AgentCore Gateway (if exists)"
echo " 2. AgentCore credential provider (if exists)"
echo " 3. Agent runtime stack (if deployed)"
echo " 4. PrivateIdpLatticeStack (if deployed)"
echo " 5. PrivateIdpGatewayInfraStack"
echo " 6. PrivateIdpPingFederateStack"
echo " 7. PrivateIdpVpcStack (may require retry if Lattice ENIs not yet released)"
echo ""
read -p "Are you sure? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
# Step 1: Delete gateway (best-effort)
echo "Deleting AgentCore Gateway..."
GATEWAY_ID=$(aws bedrock-agentcore-control list-gateways \
--query 'items[?name==`PingGateway`].gatewayId' --output text 2>/dev/null || echo "")
if [ -n "$GATEWAY_ID" ] && [ "$GATEWAY_ID" != "None" ]; then
aws bedrock-agentcore-control delete-gateway --gateway-identifier "$GATEWAY_ID" 2>/dev/null
echo " Gateway 'PingGateway' ($GATEWAY_ID) deleted."
else
echo " Gateway 'PingGateway' not found (skipping)."
fi
echo ""
# Step 2: Delete credential provider (best-effort)
echo "Deleting AgentCore credential provider..."
if aws bedrock-agentcore-control delete-oauth2-credential-provider \
--name "ping-private-idp" 2>/dev/null; then
echo " Credential provider 'ping-private-idp' deleted."
else
echo " Credential provider 'ping-private-idp' not found or already deleted (skipping)."
fi
echo ""
# Step 3: Delete agent runtime stack (best-effort)
AGENT_STACK="AgentCore-PrivateIdpPingAgent-default"
if aws cloudformation describe-stacks --stack-name "$AGENT_STACK" &>/dev/null; then
echo "Deleting agent runtime stack ($AGENT_STACK)..."
aws cloudformation delete-stack --stack-name "$AGENT_STACK"
echo " Waiting for stack deletion..."
aws cloudformation wait stack-delete-complete --stack-name "$AGENT_STACK"
echo " Agent runtime stack deleted."
else
echo "Agent runtime stack ($AGENT_STACK) not found (skipping)."
fi
echo ""
# Step 4: Delete PrivateIdpLatticeStack (if it exists)
if aws cloudformation describe-stacks --stack-name PrivateIdpLatticeStack &>/dev/null; then
echo "Destroying PrivateIdpLatticeStack..."
uv run cdk destroy PrivateIdpLatticeStack --force
fi
# Step 5: Delete PrivateIdpGatewayInfraStack
if aws cloudformation describe-stacks --stack-name PrivateIdpGatewayInfraStack &>/dev/null; then
echo "Destroying PrivateIdpGatewayInfraStack..."
uv run cdk destroy PrivateIdpGatewayInfraStack --force
fi
# Step 6: Delete PrivateIdpPingFederateStack
echo "Destroying PrivateIdpPingFederateStack..."
uv run cdk destroy PrivateIdpPingFederateStack --force
# Step 7: Try to delete PrivateIdpVpcStack — may fail if Lattice ENIs not yet released
echo "Destroying PrivateIdpVpcStack..."
if uv run cdk destroy PrivateIdpVpcStack --force; then
echo ""
echo "=========================================="
echo "Cleanup complete!"
echo "=========================================="
else
echo ""
echo "=========================================="
echo "PrivateIdpVpcStack deletion failed"
echo "=========================================="
echo ""
echo "VPC Lattice ENIs can take up to 8 hours to be released by AWS."
echo "Wait and retry with: uv run cdk destroy PrivateIdpVpcStack --force"
echo ""
echo "To check ENI status:"
echo " VPC_ID=\$(aws cloudformation describe-stacks --stack-name PrivateIdpVpcStack \\"
echo " --query 'Stacks[0].Outputs[?OutputKey==\`VpcId\`].OutputValue' --output text)"
echo " aws ec2 describe-network-interfaces --filters Name=vpc-id,Values=\$VPC_ID"
exit 1
fi
@@ -0,0 +1,46 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""CDK deployment configuration."""
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class PingFederateConfig(BaseSettings):
"""PingFederate DevOps credentials. Loaded from environment variables or .env file."""
model_config = SettingsConfigDict(
env_prefix="PING_IDENTITY_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
devops_user: str = Field(description="PingFederate DevOps user email")
devops_key: str = Field(description="PingFederate DevOps key")
class CdkConfig(BaseSettings):
"""CDK deployment configuration."""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
aws_region: str = Field(default="us-east-1", validation_alias="AWS_REGION", description="AWS region for deployment")
aws_account: str | None = Field(default=None, validation_alias="CDK_DEFAULT_ACCOUNT", description="AWS account ID")
suffix: str = Field(default="sample", description="Suffix for resource naming")
deploy_lattice: bool = Field(
default=False,
validation_alias="DEPLOY_LATTICE",
description="Deploy VPC Lattice resources (set to true for self-managed Lattice, false for AgentCore-managed)",
)
certificate_arn: str = Field(
validation_alias="CERTIFICATE_ARN",
description="ARN of a publicly trusted ACM certificate for the PingFederate domain",
)
ping_domain: str = Field(
validation_alias="PING_DOMAIN",
description="Domain name matching the ACM certificate (e.g., ping.example.com)",
)
ping_federate_config: PingFederateConfig = Field(
default_factory=PingFederateConfig,
description="PingFederate DevOps credentials",
)
@@ -0,0 +1,316 @@
#!/bin/bash
set -e
# Parse flags
DEPLOY_LATTICE=false
for arg in "$@"; do
case $arg in
--self-managed-lattice)
DEPLOY_LATTICE=true
shift
;;
*)
echo "Unknown option: $arg"
echo "Usage: ./deploy_sample.sh [--self-managed-lattice]"
exit 1
;;
esac
done
export DEPLOY_LATTICE
echo "=========================================="
echo "AgentCore Private IdP: PingFederate + VPC Lattice"
echo "=========================================="
echo ""
# Check prerequisites
echo "Checking prerequisites..."
if ! command -v uv &> /dev/null; then
echo "Error: uv is not installed"
echo " Install: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
echo " uv installed"
if ! command -v docker &> /dev/null; then
echo "Error: docker is not installed"
echo " Install: https://docs.docker.com/get-docker/"
exit 1
fi
echo " docker installed"
if ! command -v cdk &> /dev/null; then
echo "Error: AWS CDK is not installed"
echo " Install: npm install -g aws-cdk"
exit 1
fi
echo " AWS CDK installed"
if ! command -v agentcore &> /dev/null; then
echo "Error: agentcore CLI is not installed"
echo " Install: npm install -g @aws/agentcore"
exit 1
fi
echo " agentcore CLI installed ($(agentcore --version))"
if ! command -v aws &> /dev/null; then
echo "Error: AWS CLI is not installed"
echo " Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
exit 1
fi
AWS_CLI_VERSION=$(aws --version 2>&1 | sed -n 's/.*aws-cli\/\([0-9]*\.[0-9]*\).*/\1/p')
MIN_VERSION="2.27"
if [ "$(printf '%s\n' "$MIN_VERSION" "$AWS_CLI_VERSION" | sort -V | head -n1)" != "$MIN_VERSION" ]; then
echo "Error: AWS CLI version $AWS_CLI_VERSION is too old (requires >= $MIN_VERSION)"
echo " Update: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
exit 1
fi
echo " AWS CLI installed (v$AWS_CLI_VERSION)"
echo ""
echo "All prerequisites met!"
echo ""
# Check for PingFederate DevOps credentials
if [ -f .env ]; then
echo "Loading configuration from .env..."
set -a
source .env
set +a
fi
if [ -z "$PING_IDENTITY_DEVOPS_USER" ] || [ -z "$PING_IDENTITY_DEVOPS_KEY" ]; then
echo "Error: PingFederate DevOps credentials not set."
echo " Set PING_IDENTITY_DEVOPS_USER and PING_IDENTITY_DEVOPS_KEY"
echo " in your .env file or as environment variables."
echo ""
echo " Sign up at: https://devops.pingidentity.com/get-started/devopsRegistration/"
exit 1
fi
echo " PingFederate DevOps credentials found"
if [ -z "$CERTIFICATE_ARN" ]; then
echo "Error: CERTIFICATE_ARN not set."
echo " Provide the ARN of a publicly trusted ACM certificate."
echo " AgentCore Identity requires a publicly trusted TLS certificate"
echo " to connect to the private IdP via VPC Lattice."
echo ""
echo " Create one with: aws acm request-certificate --domain-name ping.example.com --validation-method DNS"
exit 1
fi
echo " CERTIFICATE_ARN found"
if [ -z "$PING_DOMAIN" ]; then
echo "Error: PING_DOMAIN not set."
echo " Set the domain name matching your ACM certificate (e.g., ping.example.com)."
exit 1
fi
echo " PING_DOMAIN found ($PING_DOMAIN)"
echo ""
# Set up virtual environment
echo "Setting up Python environment..."
uv sync
echo " Dependencies installed"
echo ""
# Deploy with CDK
echo "Deploying with CDK..."
uv run cdk bootstrap --qualifier pingidp --toolkit-stack-name CDKToolkit-pingidp
uv run cdk synth --quiet
uv run cdk deploy --all --require-approval never
echo ""
echo "=========================================="
echo "Deployment complete!"
echo "=========================================="
echo ""
echo "PingFederate was configured automatically via a Lambda custom resource"
echo "running inside the VPC (no public network access required)."
echo ""
# Get stack outputs
VPC_ID=$(aws cloudformation describe-stacks --stack-name PrivateIdpVpcStack \
--query 'Stacks[0].Outputs[?OutputKey==`VpcId`].OutputValue' --output text 2>/dev/null || echo "N/A")
SUBNET_IDS=$(aws cloudformation describe-stacks --stack-name PrivateIdpVpcStack \
--query 'Stacks[0].Outputs[?OutputKey==`PrivateSubnetIds`].OutputValue' --output text 2>/dev/null || echo "N/A")
DISCOVERY_URL=$(aws cloudformation describe-stacks --stack-name PrivateIdpPingFederateStack \
--query 'Stacks[0].Outputs[?OutputKey==`DiscoveryUrl`].OutputValue' --output text 2>/dev/null || echo "N/A")
ALB_DNS=$(aws cloudformation describe-stacks --stack-name PrivateIdpPingFederateStack \
--query 'Stacks[0].Outputs[?OutputKey==`AlbDnsName`].OutputValue' --output text 2>/dev/null || echo "N/A")
GATEWAY_ROLE_ARN=$(aws cloudformation describe-stacks --stack-name PrivateIdpGatewayInfraStack \
--query 'Stacks[0].Outputs[?OutputKey==`GatewayRoleArn`].OutputValue' --output text 2>/dev/null || echo "N/A")
MCP_ECHO_LAMBDA_ARN=$(aws cloudformation describe-stacks --stack-name PrivateIdpGatewayInfraStack \
--query 'Stacks[0].Outputs[?OutputKey==`McpEchoLambdaArn`].OutputValue' --output text 2>/dev/null || echo "N/A")
echo "Discovery URL: $DISCOVERY_URL"
echo "VPC ID: $VPC_ID"
echo "Private Subnet IDs: $SUBNET_IDS"
echo "ALB DNS Name: $ALB_DNS"
echo "Gateway Role ARN: $GATEWAY_ROLE_ARN"
echo "MCP Echo Lambda ARN: $MCP_ECHO_LAMBDA_ARN"
echo ""
# Check if Lattice stack was deployed (--self-managed-lattice)
RESOURCE_CONFIG_ID=$(aws cloudformation describe-stacks --stack-name PrivateIdpLatticeStack \
--query 'Stacks[0].Outputs[?OutputKey==`ResourceConfigurationId`].OutputValue' --output text 2>/dev/null || echo "")
# Convert comma-separated subnet IDs to JSON array
SUBNET_JSON=$(echo "$SUBNET_IDS" | sed 's/,/","/g' | sed 's/^/["/' | sed 's/$/"]/')
echo "=========================================="
echo "Step A: Create the credential provider"
echo "=========================================="
echo ""
if [ -n "$RESOURCE_CONFIG_ID" ]; then
echo "Resource Configuration ID: $RESOURCE_CONFIG_ID"
echo ""
echo "Mode: Self-managed VPC Lattice (you deployed the Lattice resources)"
echo ""
echo " aws bedrock-agentcore-control create-oauth2-credential-provider \\"
echo " --name \"ping-private-idp\" \\"
echo " --credential-provider-vendor \"CustomOauth2\" \\"
echo " --oauth2-provider-config-input '{"
echo " \"customOauth2ProviderConfig\": {"
echo " \"oauthDiscovery\": { \"discoveryUrl\": \"$DISCOVERY_URL\" },"
echo " \"clientId\": \"agentcore-client\","
echo " \"clientSecret\": \"agentcore-test-secret-12345\","
echo " \"privateEndpoint\": {"
echo " \"selfManagedLatticeResource\": {"
echo " \"resourceConfigurationIdentifier\": \"$RESOURCE_CONFIG_ID\""
echo " }"
echo " }"
echo " }"
echo " }'"
else
echo "Mode: AgentCore-managed VPC Lattice"
echo ""
echo " aws bedrock-agentcore-control create-oauth2-credential-provider \\"
echo " --name \"ping-private-idp\" \\"
echo " --credential-provider-vendor \"CustomOauth2\" \\"
echo " --oauth2-provider-config-input '{"
echo " \"customOauth2ProviderConfig\": {"
echo " \"oauthDiscovery\": { \"discoveryUrl\": \"$DISCOVERY_URL\" },"
echo " \"clientId\": \"agentcore-client\","
echo " \"clientSecret\": \"agentcore-test-secret-12345\","
echo " \"privateEndpoint\": {"
echo " \"managedVpcResource\": {"
echo " \"vpcIdentifier\": \"$VPC_ID\","
echo " \"subnetIds\": $SUBNET_JSON,"
echo " \"endpointIpAddressType\": \"IPV4\""
echo " }"
echo " }"
echo " }"
echo " }'"
fi
echo ""
echo "=========================================="
echo "Step B: Create the AgentCore Gateway"
echo "=========================================="
echo ""
echo "The gateway uses CUSTOM_JWT inbound auth with PingFederate as the token"
echo "issuer. The privateEndpoint tells the gateway to validate JWTs by reaching"
echo "PingFederate's JWKS endpoint via VPC Lattice (private connectivity)."
echo ""
echo " aws bedrock-agentcore-control create-gateway \\"
echo " --name \"PingGateway\" \\"
echo " --protocol-type \"MCP\" \\"
echo " --role-arn \"$GATEWAY_ROLE_ARN\" \\"
echo " --authorizer-type \"CUSTOM_JWT\" \\"
echo " --authorizer-configuration '{"
echo " \"customJWTAuthorizer\": {"
echo " \"discoveryUrl\": \"$DISCOVERY_URL\","
echo " \"allowedClients\": [\"agentcore-client\"],"
echo " \"privateEndpoint\": {"
echo " \"managedVpcResource\": {"
echo " \"vpcIdentifier\": \"$VPC_ID\","
echo " \"subnetIds\": $SUBNET_JSON,"
echo " \"endpointIpAddressType\": \"IPV4\""
echo " }"
echo " }"
echo " }"
echo " }' \\"
echo " --exception-level \"DEBUG\""
echo ""
echo "Wait for the gateway to become READY (~2-3 minutes):"
echo ""
echo " aws bedrock-agentcore-control list-gateways \\"
echo " --query 'items[?name==\`PingGateway\`].{id:gatewayId,status:status}'"
echo ""
echo "=========================================="
echo "Step C: Add the MCP Echo Lambda target"
echo "=========================================="
echo ""
echo "Once the gateway is READY, add the Lambda target. Replace GATEWAY_ID with"
echo "the gatewayId from the previous step:"
echo ""
echo " aws bedrock-agentcore-control create-gateway-target \\"
echo " --gateway-identifier GATEWAY_ID \\"
echo " --name \"McpEchoTarget\" \\"
echo " --target-configuration '{"
echo " \"mcp\": {"
echo " \"lambda\": {"
echo " \"lambdaArn\": \"$MCP_ECHO_LAMBDA_ARN\","
echo " \"toolSchema\": {"
echo " \"inlinePayload\": ["
echo " {"
echo " \"name\": \"get_time\","
echo " \"description\": \"Get the current UTC time\","
echo " \"inputSchema\": { \"type\": \"object\", \"properties\": {}, \"required\": [] }"
echo " },"
echo " {"
echo " \"name\": \"echo\","
echo " \"description\": \"Echo a message back\","
echo " \"inputSchema\": {"
echo " \"type\": \"object\","
echo " \"properties\": { \"message\": { \"type\": \"string\", \"description\": \"Message to echo\" } },"
echo " \"required\": [\"message\"]"
echo " }"
echo " }"
echo " ]"
echo " }"
echo " }"
echo " }"
echo " }' \\"
echo " --credential-provider-configurations '[{\"credentialProviderType\": \"GATEWAY_IAM_ROLE\"}]'"
echo ""
# Configure agent deployment target
ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text 2>/dev/null || echo "")
DEPLOY_REGION=${AWS_REGION:-$(aws configure get region 2>/dev/null || echo "us-east-1")}
if [ -n "$ACCOUNT_ID" ]; then
echo "Configuring agent deployment target..."
cat > agent/private-idp-ping-agent/agentcore/aws-targets.json <<TARGETS
[{"name": "default", "account": "$ACCOUNT_ID", "region": "$DEPLOY_REGION"}]
TARGETS
echo " aws-targets.json updated (account: $ACCOUNT_ID, region: $DEPLOY_REGION)"
echo ""
fi
# Install agent CDK dependencies
echo "Installing agent CDK dependencies..."
(cd agent/private-idp-ping-agent/agentcore/cdk && npm install --silent)
echo " Done"
echo ""
echo "=========================================="
echo "Step D: Deploy the runtime"
echo "=========================================="
echo ""
echo "Set GATEWAY_URL to the gateway's MCP endpoint, then deploy:"
echo ""
echo " cd agent/private-idp-ping-agent"
echo " agentcore deploy -y"
echo ""
echo "Then test with:"
echo " agentcore invoke --prompt \"test\""
echo ""
echo "See README.md for full instructions."
@@ -0,0 +1,428 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""Lambda handler that configures PingFederate via the Admin API.
Runs as a CDK custom resource inside the VPC so it can reach the internal ALB directly.
"""
import json
import logging
import os
import time
import urllib.request
import ssl
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# PingFederate configuration constants
CLIENT_ID = "agentcore-client"
CLIENT_SECRET = "agentcore-test-secret-12345"
ATM_ID = "agentcoreJwtAtm"
OIDC_POLICY_ID = "agentcoreOidcPolicy"
SIGNING_KEY_ID = "agentcore-signing-key"
def handler(event, context):
"""CloudFormation custom resource handler."""
request_type = event.get("RequestType", "")
response_url = event.get("ResponseURL", "")
stack_id = event.get("StackId", "")
request_id = event.get("RequestId", "")
logical_id = event.get("LogicalResourceId", "")
physical_id = event.get("PhysicalResourceId", logical_id)
props = event.get("ResourceProperties", {})
admin_url = props.get("AdminUrl", "")
admin_user = props.get("AdminUser", "")
secret_id = props.get("SecretId", "")
base_url = props.get("BaseUrl", "")
# Fetch admin password from Secrets Manager. Wrapped in retry logic because Lambda
# VPC ENIs can take a few seconds to initialize on cold start, causing
# "[Errno 16] Device or resource busy" errors on the first network call.
sm = boto3.client("secretsmanager")
secret_value = json.loads(
_retry_on_eni_busy(lambda: sm.get_secret_value(SecretId=secret_id))["SecretString"]
)
admin_password = secret_value["adminPassword"]
try:
if request_type in ("Create", "Update"):
configure_pingfederate(admin_url, admin_user, admin_password, base_url)
discovery_url = f"{base_url}/.well-known/openid-configuration"
send_response(response_url, "SUCCESS", stack_id, request_id, logical_id, physical_id, {
"DiscoveryUrl": discovery_url,
"ClientId": CLIENT_ID,
})
else:
# Delete — nothing to tear down
send_response(response_url, "SUCCESS", stack_id, request_id, logical_id, physical_id)
except Exception as e:
logger.exception("Configuration failed")
send_response(response_url, "FAILED", stack_id, request_id, logical_id, physical_id, reason=str(e))
def _retry_on_eni_busy(fn, max_attempts=6, delay=5):
"""Retry a callable that may fail with '[Errno 16] Device or resource busy'.
Lambda functions in a VPC can experience transient OSError on cold start while
the ENI is being attached to the execution environment. This retries for up to
30 seconds (6 attempts × 5s) before giving up.
"""
for attempt in range(max_attempts):
try:
return fn()
except OSError as e:
if attempt == max_attempts - 1:
raise
logger.warning(f"VPC ENI not ready (attempt {attempt + 1}/{max_attempts}): {e}")
time.sleep(delay)
def configure_pingfederate(admin_url, admin_user, admin_password, base_url):
"""Configure PingFederate OAuth/OIDC via the Admin API."""
api = f"{admin_url}/pf-admin-api/v1"
auth = _basic_auth(admin_user, admin_password)
ctx = _insecure_ssl_context()
# Wait for PingFederate to be ready (up to 8 minutes).
# PingFederate can take 3-5 min to start, plus the ALB target group
# needs to pass health checks before it routes traffic.
logger.info("Waiting for PingFederate to be ready...")
max_attempts = 96 # 96 × 5s = 8 minutes
for i in range(max_attempts):
try:
_api_call("GET", f"{api}/version", auth=auth, ssl_ctx=ctx)
logger.info("PingFederate is ready")
break
except Exception:
if i == max_attempts - 1:
raise TimeoutError(f"PingFederate not ready after {max_attempts} attempts")
time.sleep(5)
# 1. Generate signing key pair
logger.info("1. Creating signing key pair...")
_api_call("POST", f"{api}/keyPairs/signing/generate", auth=auth, ssl_ctx=ctx, body={
"id": SIGNING_KEY_ID,
"commonName": "AgentCore Signing Key",
"organization": "AgentCore Sample",
"country": "US",
"validDays": 3650,
"keyAlgorithm": "RSA",
"keySize": 2048,
"signatureAlgorithm": "SHA256withRSA",
})
# 2. Create JWT Access Token Manager
logger.info("2. Creating JWT Access Token Manager...")
_api_call("POST", f"{api}/oauth/accessTokenManagers", auth=auth, ssl_ctx=ctx, body={
"id": ATM_ID,
"name": "AgentCore JWT Token Manager",
"pluginDescriptorRef": {
"id": "com.pingidentity.pf.access.token.management.plugins.JwtBearerAccessTokenManagementPlugin"
},
"configuration": {
"tables": [
{"name": "Symmetric Keys", "rows": []},
{"name": "Certificates", "rows": []},
],
"fields": [
{"name": "Token Lifetime", "value": "120"},
{"name": "Use Centralized Signing Key", "value": "true"},
{"name": "JWS Algorithm", "value": "RS256"},
{"name": "Active Symmetric Key ID", "value": ""},
{"name": "Active Signing Certificate Key ID", "value": ""},
{"name": "JWE Algorithm", "value": ""},
{"name": "JWE Content Encryption Algorithm", "value": ""},
{"name": "Active Symmetric Encryption Key ID", "value": ""},
{"name": "Asymmetric Encryption Key", "value": ""},
{"name": "Asymmetric Encryption JWKS URL", "value": ""},
{"name": "Enable Token Revocation", "value": "false"},
{"name": "Include Key ID Header Parameter", "value": "true"},
{"name": "Include Issued At Claim", "value": "true"},
{"name": "Client ID Claim Name", "value": "client_id"},
{"name": "Scope Claim Name", "value": "scope"},
{"name": "Space Delimit Scope Values", "value": "true"},
{"name": "JWT ID Claim Length", "value": "22"},
{"name": "Include X.509 Thumbprint Header Parameter", "value": "false"},
{"name": "Default JWKS URL Cache Duration", "value": "720"},
{"name": "Include JWE Key ID Header Parameter", "value": "true"},
{"name": "Include JWE X.509 Thumbprint Header Parameter", "value": "false"},
{"name": "Authorization Details Claim Name", "value": "authorization_details"},
{"name": "Issuer Claim Value", "value": base_url},
{"name": "Audience Claim Value", "value": ""},
{"name": "Not Before Claim Offset", "value": ""},
{"name": "Access Grant GUID Claim Name", "value": ""},
{"name": "Publish Keys to the PingFederate JWKS Endpoint", "value": "false"},
{"name": "JWKS Endpoint Path", "value": ""},
{"name": "JWKS Endpoint Cache Duration", "value": "720"},
{"name": "Publish Key ID X.509 URL", "value": "false"},
{"name": "Publish Thumbprint X.509 URL", "value": "false"},
{"name": "Expand Scope Groups", "value": "false"},
{"name": "Type Header Value", "value": ""},
],
},
"attributeContract": {
"coreAttributes": [],
"extendedAttributes": [
{"name": "sub", "multiValued": False},
{"name": "scope", "multiValued": False},
{"name": "client_id", "multiValued": False},
],
"defaultSubjectAttribute": "sub",
},
"selectionSettings": {"resourceUris": []},
"accessControlSettings": {"restrictClients": False, "allowedClients": []},
"sessionValidationSettings": {
"checkValidAuthnSession": False,
"checkSessionRevocationStatus": False,
"updateAuthnSessionActivity": False,
"includeSessionId": False,
},
})
# 3. Set default ATM
logger.info("3. Setting default access token manager...")
_api_call("PUT", f"{api}/oauth/accessTokenManagers/settings", auth=auth, ssl_ctx=ctx, body={
"defaultAccessTokenManagerRef": {"id": ATM_ID}
})
# 4. Configure OAuth auth server settings
logger.info("4. Configuring OAuth auth server settings...")
_api_call("PUT", f"{api}/oauth/authServerSettings", auth=auth, ssl_ctx=ctx, body={
"defaultScopeDescription": "",
"scopes": [
{"name": "openid", "description": "OpenID Connect", "dynamic": False},
{"name": "profile", "description": "User profile", "dynamic": False},
{"name": "email", "description": "Email", "dynamic": False},
],
"scopeGroups": [],
"exclusiveScopes": [],
"exclusiveScopeGroups": [],
"authorizationCodeTimeout": 60,
"authorizationCodeEntropy": 30,
"disallowPlainPKCE": False,
"includeIssuerInAuthorizationResponse": False,
"persistentGrantLifetime": -1,
"persistentGrantLifetimeUnit": "DAYS",
"persistentGrantIdleTimeout": 30,
"persistentGrantIdleTimeoutTimeUnit": "DAYS",
"refreshTokenLength": 42,
"rollRefreshTokenValues": False,
"refreshTokenRollingGracePeriod": 60,
"refreshRollingInterval": 0,
"refreshRollingIntervalTimeUnit": "HOURS",
"persistentGrantReuseGrantTypes": ["IMPLICIT"],
"persistentGrantContract": {
"extendedAttributes": [],
"coreAttributes": [{"name": "USER_KEY"}, {"name": "USER_NAME"}],
},
"bypassAuthorizationForApprovedGrants": False,
"allowUnidentifiedClientROCreds": False,
"allowUnidentifiedClientExtensionGrants": False,
"tokenEndpointBaseUrl": base_url,
"parReferenceTimeout": 60,
"parReferenceLength": 24,
"parStatus": "ENABLED",
"clientSecretRetentionPeriod": 0,
"jwtSecuredAuthorizationResponseModeLifetime": 600,
"dpopProofRequireNonce": False,
"dpopProofLifetimeSeconds": 120,
"dpopProofEnforceReplayPrevention": False,
"bypassAuthorizationForApprovedConsents": False,
"consentLifetimeDays": -1,
})
# 5. Configure server settings
logger.info("5. Configuring server settings...")
_api_call("PUT", f"{api}/serverSettings", auth=auth, ssl_ctx=ctx, body={
"contactInfo": {},
"rolesAndProtocols": {
"oauthRole": {"enableOauth": True, "enableOpenIdConnect": True},
"idpRole": {
"enable": True,
"enableSaml11": True,
"enableSaml10": True,
"enableWsFed": True,
"enableWsTrust": True,
"saml20Profile": {"enable": True},
"enableOutboundProvisioning": True,
},
"spRole": {
"enable": True,
"enableSaml11": True,
"enableSaml10": True,
"enableWsFed": True,
"enableWsTrust": True,
"saml20Profile": {"enable": True, "enableXASP": True},
"enableInboundProvisioning": True,
"enableOpenIDConnect": True,
},
"enableIdpDiscovery": True,
},
"federationInfo": {
"baseUrl": base_url,
"saml2EntityId": "evaluation",
"saml1xIssuerId": "",
"saml1xSourceId": "",
"wsfedRealm": "",
},
})
# 6. Create OIDC policy
logger.info("6. Creating OIDC policy...")
_api_call("POST", f"{api}/oauth/openIdConnect/policies", auth=auth, ssl_ctx=ctx, body={
"id": OIDC_POLICY_ID,
"name": "AgentCore OIDC Policy",
"idTokenLifetime": 5,
"attributeContract": {
"coreAttributes": [{"name": "sub", "multiValued": False}],
"extendedAttributes": [
{"name": "name", "multiValued": False},
{"name": "email", "multiValued": False},
],
},
"attributeMapping": {
"attributeSources": [],
"attributeContractFulfillment": {
"sub": {"source": {"type": "NO_MAPPING"}},
"name": {"source": {"type": "NO_MAPPING"}},
"email": {"source": {"type": "NO_MAPPING"}},
},
"issuanceCriteria": {"conditionalCriteria": []},
},
"includeSriInIdToken": True,
"includeUserInfoInIdToken": False,
"includeSHashInIdToken": False,
"includeX5tInIdToken": False,
"idTokenTypHeaderValue": "",
"returnIdTokenOnRefreshGrant": False,
"reissueIdTokenInHybridFlow": False,
"accessTokenManagerRef": {"id": ATM_ID},
"scopeAttributeMappings": {},
})
# 7. Set default OIDC policy
logger.info("7. Setting default OIDC policy...")
_api_call("PUT", f"{api}/oauth/openIdConnect/settings", auth=auth, ssl_ctx=ctx, body={
"defaultPolicyRef": {"id": OIDC_POLICY_ID},
"sessionSettings": {
"trackUserSessionsForLogout": False,
"revokeUserSessionOnLogout": True,
"sessionRevocationLifetime": 490,
},
})
# 8. Create OAuth client
logger.info("8. Creating OAuth client...")
_api_call("POST", f"{api}/oauth/clients", auth=auth, ssl_ctx=ctx, body={
"clientId": CLIENT_ID,
"enabled": True,
"redirectUris": [
f"https://bedrock-agentcore.{os.environ.get('AWS_REGION', 'us-east-1')}.amazonaws.com/identities/oauth2/callback",
"https://localhost/callback",
],
"grantTypes": ["AUTHORIZATION_CODE", "CLIENT_CREDENTIALS", "REFRESH_TOKEN"],
"name": "AgentCore OAuth Client",
"refreshRolling": "SERVER_DEFAULT",
"refreshTokenRollingIntervalType": "SERVER_DEFAULT",
"persistentGrantExpirationType": "SERVER_DEFAULT",
"persistentGrantIdleTimeoutType": "SERVER_DEFAULT",
"persistentGrantReuseType": "SERVER_DEFAULT",
"bypassApprovalPage": True,
"restrictScopes": False,
"restrictedScopes": [],
"exclusiveScopes": [],
"restrictedResponseTypes": [],
"defaultAccessTokenManagerRef": {"id": ATM_ID},
"restrictToDefaultAccessTokenManager": False,
"oidcPolicy": {
"grantAccessSessionRevocationApi": False,
"grantAccessSessionSessionManagementApi": False,
"logoutMode": "NONE",
"pingAccessLogoutCapable": False,
"pairwiseIdentifierUserType": False,
},
"clientAuth": {
"type": "SECRET",
"secret": CLIENT_SECRET,
"secondarySecrets": [],
},
"deviceFlowSettingType": "SERVER_DEFAULT",
"requireProofKeyForCodeExchange": False,
"refreshTokenRollingGracePeriodType": "SERVER_DEFAULT",
"clientSecretRetentionPeriodType": "SERVER_DEFAULT",
"requireDpop": False,
"requireSignedRequests": False,
})
# Verify: request a token using the ALB internal DNS name (not the public domain,
# which may not resolve from within the VPC). The engine listener is on port 443.
logger.info("Verifying: requesting client_credentials token...")
alb_host = admin_url.split("//")[1].split(":")[0] # extract ALB DNS name
token_resp = _token_request(f"https://{alb_host}", ctx)
if "access_token" not in token_resp:
raise RuntimeError(f"Token verification failed: {token_resp}")
logger.info("Configuration complete — token verification successful")
def _token_request(base_url, ssl_ctx):
"""Request a client_credentials token to verify the configuration."""
url = f"{base_url}/as/token.oauth2"
data = f"grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&scope=openid"
req = urllib.request.Request(url, data=data.encode(), method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:
return json.loads(resp.read())
def _api_call(method, url, auth, ssl_ctx, body=None):
"""Make an API call to PingFederate."""
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", auth)
req.add_header("X-XSRF-Header", "PingFederate")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, context=ssl_ctx, timeout=30) as resp:
resp_body = resp.read()
if resp_body:
result = json.loads(resp_body)
if "resultId" in result:
raise RuntimeError(f"API call failed: {result}")
return result
return {}
def _basic_auth(user, password):
"""Return a Basic auth header value."""
import base64
credentials = base64.b64encode(f"{user}:{password}".encode()).decode()
return f"Basic {credentials}"
def _insecure_ssl_context():
"""Create an SSL context that skips certificate verification (private CA)."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def send_response(response_url, status, stack_id, request_id, logical_id, physical_id, data=None, reason=""):
"""Send a response to the CloudFormation custom resource."""
body = json.dumps({
"Status": status,
"Reason": reason or f"See CloudWatch Log Stream",
"PhysicalResourceId": physical_id,
"StackId": stack_id,
"RequestId": request_id,
"LogicalResourceId": logical_id,
"Data": data or {},
}).encode()
req = urllib.request.Request(response_url, data=body, method="PUT")
req.add_header("Content-Type", "")
req.add_header("Content-Length", str(len(body)))
urllib.request.urlopen(req, timeout=30)
@@ -0,0 +1,88 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""Minimal MCP server on Lambda — gateway target for the inbound auth demo.
Exposes two tools (get_time, echo) via the MCP Streamable HTTP protocol.
Deployed by CDK as part of the PingFederate sample.
"""
import json
from datetime import datetime, timezone
TOOLS = [
{
"name": "get_time",
"description": "Get the current UTC time",
"inputSchema": {"type": "object", "properties": {}, "required": []},
},
{
"name": "echo",
"description": "Echo a message back",
"inputSchema": {
"type": "object",
"properties": {"message": {"type": "string", "description": "Message to echo"}},
"required": ["message"],
},
},
]
def handle_request(body):
method = body.get("method", "")
params = body.get("params", {})
req_id = body.get("id")
if method == "initialize":
result = {
"protocolVersion": params.get("protocolVersion", "2025-03-26"),
"capabilities": {"tools": {}},
"serverInfo": {"name": "PingFederateDemoMCP", "version": "1.0.0"},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
name = params.get("name")
args = params.get("arguments", {})
if name == "get_time":
text = datetime.now(timezone.utc).isoformat()
elif name == "echo":
text = f"Echo: {args.get('message', '')}"
else:
return {
"jsonrpc": "2.0",
"error": {"code": -32601, "message": f"Unknown tool: {name}"},
"id": req_id,
}
result = {"content": [{"type": "text", "text": text}]}
elif method in ("notifications/initialized", "notifications/cancelled"):
return None
else:
return {
"jsonrpc": "2.0",
"error": {"code": -32601, "message": f"Method not found: {method}"},
"id": req_id,
}
return {"jsonrpc": "2.0", "result": result, "id": req_id}
def handler(event, context):
try:
body = json.loads(event.get("body") or "{}")
if isinstance(body, list):
responses = [r for r in (handle_request(r) for r in body) if r is not None]
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(responses),
}
response = handle_request(body)
if response is None:
return {"statusCode": 202, "body": ""}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(response),
}
except Exception as e:
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
@@ -0,0 +1,31 @@
[project]
name = "agentcore-private-idp-ping-federate"
version = "0.1.0"
description = "Private IdP connectivity from AgentCore Identity to PingFederate via VPC Lattice"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"aws-cdk-lib>=2.237.1",
"cdk-ecr-deployment>=3.0.0",
"constructs>=10.0.0",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
]
[dependency-groups]
dev = [
"cdk-nag>=2.30.0",
"pytest>=8.0.0",
"checkov>=3.2.459",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["test"]
[tool.black]
line-length = 120
[tool.isort]
profile = "black"
line_length = 120
@@ -0,0 +1,2 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
@@ -0,0 +1,57 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""Gateway infrastructure stack — MCP Echo Lambda + IAM role for the gateway."""
from aws_cdk import CfnOutput, Duration, Stack
from aws_cdk import aws_iam as iam
from aws_cdk import aws_lambda as lambda_
from constructs import Construct
class GatewayInfraStack(Stack):
"""Deploy the infrastructure needed for the AgentCore Gateway demo.
Creates:
- A minimal MCP Echo Lambda (gateway target)
- An IAM role for the gateway (trusts bedrock-agentcore.amazonaws.com)
"""
def __init__(self, scope: Construct, id: str, **kwargs):
super().__init__(scope, id, **kwargs)
# --- MCP Echo Lambda ---
self.mcp_echo_fn = lambda_.Function(
self,
"McpEchoFn",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambda/mcp_echo"),
timeout=Duration.seconds(30),
memory_size=128,
)
# --- Gateway IAM Role ---
# The condition block is omitted so the role can be used before the
# gateway ID is known. For production use, add SourceAccount and
# SourceArn conditions after gateway creation.
self.gateway_role = iam.Role(
self,
"GatewayRole",
assumed_by=iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
)
self.mcp_echo_fn.grant_invoke(self.gateway_role)
# --- Outputs ---
CfnOutput(
self,
"McpEchoLambdaArn",
value=self.mcp_echo_fn.function_arn,
description="Lambda ARN for the MCP Echo gateway target",
)
CfnOutput(
self,
"GatewayRoleArn",
value=self.gateway_role.role_arn,
description="IAM role ARN for the AgentCore Gateway",
)
@@ -0,0 +1,93 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""VPC Lattice stack — resource gateway and resource configuration for private IdP connectivity."""
from aws_cdk import CfnOutput, Stack
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_elasticloadbalancingv2 as elbv2
from aws_cdk import aws_vpclattice as vpclattice
from constructs import Construct
class LatticeStack(Stack):
"""Create VPC Lattice resources that expose the internal PingFederate ALB to AgentCore Identity.
This stack creates:
1. A Resource Gateway — ENIs in the VPC that serve as the ingress point for Lattice traffic.
2. A Resource Configuration — describes the PingFederate ALB (DNS + port) so that
AgentCore Identity can reach it privately via the ``selfManagedLatticeResource``
attribute on the OAuth2 credential provider.
"""
def __init__(
self,
scope: Construct,
id: str,
vpc: ec2.IVpc,
alb: elbv2.IApplicationLoadBalancer,
alb_listener: elbv2.IApplicationListener,
suffix: str,
**kwargs,
):
"""Initialize Lattice stack."""
super().__init__(scope, id, **kwargs)
# Security group for the resource gateway — allows HTTPS traffic from Lattice to the ALB
gw_sg = ec2.SecurityGroup(
self,
"ResourceGatewaySg",
vpc=vpc,
description="VPC Lattice resource gateway security group",
allow_all_outbound=True,
)
gw_sg.add_ingress_rule(ec2.Peer.ipv4(vpc.vpc_cidr_block), ec2.Port.tcp(443), "HTTPS from VPC")
# Resource Gateway — place ENIs in the private subnets of the VPC where PingFederate runs.
private_subnet_ids = [s.subnet_id for s in vpc.private_subnets]
resource_gateway = vpclattice.CfnResourceGateway(
self,
"ResourceGateway",
name=f"ping-idp-gw-{suffix}",
vpc_identifier=vpc.vpc_id,
subnet_ids=private_subnet_ids,
security_group_ids=[gw_sg.security_group_id],
ip_address_type="IPV4",
)
# Resource Configuration — a SINGLE resource pointing to the internal ALB by DNS name.
# AgentCore Identity uses the resourceConfigurationIdentifier (rcfg-xxx) to reach
# PingFederate privately through VPC Lattice.
resource_config = vpclattice.CfnResourceConfiguration(
self,
"ResourceConfiguration",
name=f"ping-idp-rcfg-{suffix}",
resource_configuration_type="SINGLE",
protocol_type="TCP",
port_ranges=["443"],
resource_gateway_id=resource_gateway.attr_id,
resource_configuration_definition=vpclattice.CfnResourceConfiguration.ResourceConfigurationDefinitionProperty(
dns_resource=vpclattice.CfnResourceConfiguration.DnsResourceProperty(
domain_name=alb.load_balancer_dns_name,
ip_address_type="IPV4",
),
),
allow_association_to_sharable_service_network=True,
)
resource_config.add_dependency(resource_gateway)
# The resource configuration ID (rcfg-xxx) is what AgentCore Identity needs
self.resource_configuration_id = resource_config.attr_id
CfnOutput(
self,
"ResourceGatewayId",
value=resource_gateway.attr_id,
description="VPC Lattice Resource Gateway ID",
)
CfnOutput(
self,
"ResourceConfigurationId",
value=resource_config.attr_id,
description="VPC Lattice Resource Configuration ID — use this in the AgentCore Identity OAuth2 provider",
)
@@ -0,0 +1,336 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""PingFederate IdP stack — ECS Fargate, internal ALB, ECR, EFS."""
from aws_cdk import CfnOutput, CustomResource, Duration, RemovalPolicy, Stack
from aws_cdk import aws_certificatemanager as acm
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ecr as ecr
from aws_cdk import aws_ecs as ecs
from aws_cdk import aws_efs as efs
from aws_cdk import aws_elasticloadbalancingv2 as elbv2
from aws_cdk import aws_iam as iam
from aws_cdk import aws_lambda as lambda_
from aws_cdk import aws_logs as logs
from aws_cdk import aws_route53 as route53
from aws_cdk import aws_route53_targets as targets
from aws_cdk import aws_secretsmanager as secretsmanager
from cdk_ecr_deployment import DockerImageName, ECRDeployment
from constructs import Construct
from config import CdkConfig
PING_FEDERATE_ENGINE_PORT = 9031
PING_FEDERATE_ADMIN_PORT = 9999
class PingFederateStack(Stack):
"""Deploy a self-hosted PingFederate IdP on ECS Fargate behind an internal ALB."""
def __init__(self, scope: Construct, id: str, vpc: ec2.IVpc, config: CdkConfig, **kwargs):
"""Initialize PingFederate stack."""
super().__init__(scope, id, **kwargs)
self.vpc = vpc
self.ping_domain = config.ping_domain
# --- Public ACM Certificate (user-provided) ---
# AgentCore Identity requires a publicly trusted TLS certificate to connect
# to the private IdP via VPC Lattice. The ALB remains internal (not internet-facing).
certificate = acm.Certificate.from_certificate_arn(
self, "AlbCertificate", config.certificate_arn
)
# --- Secrets Manager ---
ping_secret = secretsmanager.Secret(
self,
"PingFederateSecret",
secret_name=f"pingfederate-devops-{config.suffix}",
generate_secret_string=secretsmanager.SecretStringGenerator(
generate_string_key="adminPassword",
exclude_punctuation=True,
password_length=20,
secret_string_template=(
f'{{"username":"{config.ping_federate_config.devops_user}",'
f'"key":"{config.ping_federate_config.devops_key}",'
f'"adminUsername":"Administrator"}}'
),
),
)
# --- EFS ---
file_system = efs.FileSystem(
self,
"FileSystem",
vpc=self.vpc,
removal_policy=RemovalPolicy.DESTROY,
encrypted=True,
)
access_point = efs.AccessPoint(
self,
"AccessPoint",
file_system=file_system,
path="/pingfederate",
posix_user=efs.PosixUser(uid="9031", gid="9999"),
create_acl=efs.Acl(owner_uid="9031", owner_gid="9999", permissions="755"),
)
# --- ECR ---
ecr_repo = ecr.Repository(
self,
"EcrRepo",
repository_name=f"pingfederate-{config.suffix}",
removal_policy=RemovalPolicy.DESTROY,
empty_on_delete=True,
)
ECRDeployment(
self,
"ImageDeployment",
src=DockerImageName("pingidentity/pingfederate:latest"),
dest=DockerImageName(f"{ecr_repo.repository_uri}:latest"),
)
# --- ECS Cluster + Task Definition ---
cluster = ecs.Cluster(
self,
"Cluster",
vpc=self.vpc,
container_insights_v2=ecs.ContainerInsights.ENABLED,
)
execution_role = iam.Role(
self,
"ExecutionRole",
assumed_by=iam.CompositePrincipal(
iam.ServicePrincipal("ecs.amazonaws.com"),
iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
),
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name("AmazonEC2ContainerRegistryReadOnly"),
],
)
task_def = ecs.FargateTaskDefinition(
self,
"TaskDef",
cpu=2048,
memory_limit_mib=4096,
execution_role=execution_role,
)
log_group = logs.LogGroup(
self,
"LogGroup",
retention=logs.RetentionDays.ONE_MONTH,
removal_policy=RemovalPolicy.DESTROY,
)
environment = {
"PING_IDENTITY_ACCEPT_EULA": "YES",
"CREATE_INITIAL_ADMIN_USER": "true",
"PF_ADMIN_PUBLIC_HOSTNAME": self.ping_domain,
"PF_ADMIN_PUBLIC_BASEURL": f"https://{self.ping_domain}:{PING_FEDERATE_ADMIN_PORT}",
"PF_ENGINE_PUBLIC_HOSTNAME": self.ping_domain,
"PF_ENGINE_BASE_URL": f"https://{self.ping_domain}",
}
secrets = {
"PING_IDENTITY_DEVOPS_USER": ecs.Secret.from_secrets_manager(ping_secret, "username"),
"PING_IDENTITY_DEVOPS_KEY": ecs.Secret.from_secrets_manager(ping_secret, "key"),
"PING_IDENTITY_PASSWORD": ecs.Secret.from_secrets_manager(ping_secret, "adminPassword"),
}
container = task_def.add_container(
"pingfederate",
image=ecs.ContainerImage.from_registry(f"{ecr_repo.repository_uri}:latest"),
environment=environment,
secrets=secrets,
logging=ecs.LogDrivers.aws_logs(stream_prefix="pingfederate", log_group=log_group),
)
container.add_port_mappings(
ecs.PortMapping(container_port=PING_FEDERATE_ENGINE_PORT),
ecs.PortMapping(container_port=PING_FEDERATE_ADMIN_PORT),
)
# EFS volume
task_def.add_volume(
name="pingfederate-data",
efs_volume_configuration=ecs.EfsVolumeConfiguration(
file_system_id=file_system.file_system_id,
transit_encryption="ENABLED",
authorization_config=ecs.AuthorizationConfig(
access_point_id=access_point.access_point_id,
iam="ENABLED",
),
),
)
container.add_mount_points(
ecs.MountPoint(
source_volume="pingfederate-data",
container_path="/opt/out/instance",
read_only=False,
)
)
file_system.grant_read_write(task_def.task_role)
ping_secret.grant_read(execution_role)
# --- ECS Service ---
service = ecs.FargateService(
self,
"Service",
cluster=cluster,
task_definition=task_def,
circuit_breaker=ecs.DeploymentCircuitBreaker(rollback=True),
desired_count=1,
health_check_grace_period=Duration.seconds(120),
platform_version=ecs.FargatePlatformVersion.LATEST,
)
file_system.connections.allow_default_port_from(service)
# --- Internal ALB ---
alb_sg = ec2.SecurityGroup(
self,
"AlbSg",
vpc=self.vpc,
description="Internal ALB security group",
allow_all_outbound=True,
)
alb_sg.add_ingress_rule(ec2.Peer.ipv4(self.vpc.vpc_cidr_block), ec2.Port.tcp(443), "HTTPS from VPC")
alb_sg.add_ingress_rule(
ec2.Peer.ipv4(self.vpc.vpc_cidr_block),
ec2.Port.tcp(PING_FEDERATE_ADMIN_PORT),
"HTTPS admin from VPC",
)
self.alb = elbv2.ApplicationLoadBalancer(
self,
"InternalAlb",
vpc=self.vpc,
internet_facing=False,
security_group=alb_sg,
drop_invalid_header_fields=True,
)
self.alb_listener = self.alb.add_listener(
"HttpsListener",
port=443,
protocol=elbv2.ApplicationProtocol.HTTPS,
certificates=[certificate],
ssl_policy=elbv2.SslPolicy.TLS12,
)
self.alb_listener.add_targets(
"EngineTarget",
targets=[
service.load_balancer_target(
container_name="pingfederate",
container_port=PING_FEDERATE_ENGINE_PORT,
)
],
health_check=elbv2.HealthCheck(
healthy_threshold_count=3,
path="/pf/heartbeat.ping",
),
slow_start=Duration.seconds(60),
port=PING_FEDERATE_ENGINE_PORT,
protocol=elbv2.ApplicationProtocol.HTTPS,
)
# Admin API listener (port 9999) — used by the Lambda custom resource
admin_listener = self.alb.add_listener(
"AdminListener",
port=PING_FEDERATE_ADMIN_PORT,
protocol=elbv2.ApplicationProtocol.HTTPS,
certificates=[certificate],
ssl_policy=elbv2.SslPolicy.TLS12,
)
admin_listener.add_targets(
"AdminTarget",
targets=[
service.load_balancer_target(
container_name="pingfederate",
container_port=PING_FEDERATE_ADMIN_PORT,
)
],
health_check=elbv2.HealthCheck(
healthy_threshold_count=3,
path="/pf/heartbeat.ping",
port=str(PING_FEDERATE_ADMIN_PORT),
),
port=PING_FEDERATE_ADMIN_PORT,
protocol=elbv2.ApplicationProtocol.HTTPS,
)
# --- Private Hosted Zone ---
# The VPC Lattice resource gateway resolves the discovery URL domain from within
# the VPC. A private hosted zone maps the certificate domain to the internal ALB
# so that AgentCore Identity can reach PingFederate via its public domain name.
private_zone = route53.PrivateHostedZone(
self,
"PrivateZone",
zone_name=self.ping_domain,
vpc=self.vpc,
)
route53.ARecord(
self,
"AlbAliasRecord",
zone=private_zone,
target=route53.RecordTarget.from_alias(targets.LoadBalancerTarget(self.alb)),
)
# --- Lambda Custom Resource: Configure PingFederate ---
configure_fn = lambda_.Function(
self,
"ConfigurePingFedFn",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="index.handler",
code=lambda_.Code.from_asset("lambda/configure_pingfed"),
vpc=self.vpc,
vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
timeout=Duration.minutes(10),
memory_size=256,
log_retention=logs.RetentionDays.ONE_MONTH,
)
# Allow the Lambda to reach the internal ALB (HTTPS on engine + admin ports)
configure_fn.connections.allow_to(alb_sg, ec2.Port.tcp(443), "HTTPS to ALB engine")
configure_fn.connections.allow_to(alb_sg, ec2.Port.tcp(PING_FEDERATE_ADMIN_PORT), "HTTPS to ALB admin")
# Allow the Lambda to read the admin password from Secrets Manager
ping_secret.grant_read(configure_fn)
admin_url = f"https://{self.alb.load_balancer_dns_name}:{PING_FEDERATE_ADMIN_PORT}"
engine_url = f"https://{self.ping_domain}"
configure_resource = CustomResource(
self,
"ConfigurePingFed",
service_token=configure_fn.function_arn,
properties={
"AdminUrl": admin_url,
"AdminUser": "Administrator",
"SecretId": ping_secret.secret_name,
"BaseUrl": engine_url,
},
)
configure_resource.node.add_dependency(service)
# --- Outputs ---
self.discovery_url = (
f"https://{self.ping_domain}/.well-known/openid-configuration"
)
CfnOutput(self, "SecretName", value=ping_secret.secret_name)
CfnOutput(self, "AlbDnsName", value=self.alb.load_balancer_dns_name)
CfnOutput(self, "AlbArn", value=self.alb.load_balancer_arn)
CfnOutput(
self,
"DiscoveryUrl",
value=self.discovery_url,
description="PingFederate OIDC discovery URL (uses public domain, reachable via VPC Lattice)",
)
CfnOutput(self, "PingDomain", value=self.ping_domain)
@@ -0,0 +1,49 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0
"""VPC stack — shared network infrastructure for PingFederate and VPC Lattice."""
from aws_cdk import CfnOutput, Stack
from aws_cdk import aws_ec2 as ec2
from constructs import Construct
class VpcStack(Stack):
"""Create a VPC with public and private subnets.
This stack is separate from PingFederateStack so that it can be deleted
independently. VPC Lattice resource gateways create ENIs that can take
up to 8 hours to release after deletion — separating the VPC allows users
to delete other stacks first, then retry VPC deletion later.
"""
def __init__(self, scope: Construct, id: str, **kwargs):
"""Initialize VPC stack."""
super().__init__(scope, id, **kwargs)
self.vpc = ec2.Vpc(
self,
"Vpc",
max_azs=2,
nat_gateways=1,
subnet_configuration=[
ec2.SubnetConfiguration(
name="Private",
subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidr_mask=24,
),
ec2.SubnetConfiguration(
name="Public",
subnet_type=ec2.SubnetType.PUBLIC,
cidr_mask=24,
map_public_ip_on_launch=False,
),
],
)
CfnOutput(self, "VpcId", value=self.vpc.vpc_id)
CfnOutput(
self,
"PrivateSubnetIds",
value=",".join([s.subnet_id for s in self.vpc.private_subnets]),
description="Private subnet IDs (for AgentCore Identity managedVpcResource)",
)