initial (#1431)
This commit is contained in:
committed by
GitHub
parent
27b7022a8c
commit
43b7a14354
@@ -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 15–20 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 ~2–3 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.
|
||||
+141
@@ -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 |
|
||||
+104
@@ -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/)
|
||||
+12
@@ -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/
|
||||
+16
@@ -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`).
|
||||
+96
@@ -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
|
||||
}
|
||||
+45
@@ -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';
|
||||
+39
@@ -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": []
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
[{"name": "default", "account": "837460776723", "region": "us-east-1"}]
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# CDK asset staging directory
|
||||
.cdk.staging
|
||||
cdk.out
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
*.ts
|
||||
!*.d.ts
|
||||
|
||||
# CDK asset staging directory
|
||||
.cdk.staging
|
||||
cdk.out
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
+26
@@ -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
|
||||
```
|
||||
+91
@@ -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;
|
||||
});
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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'],
|
||||
};
|
||||
+5777
File diff suppressed because it is too large
Load Diff
+30
@@ -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"
|
||||
}
|
||||
}
|
||||
+28
@@ -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',
|
||||
});
|
||||
});
|
||||
+28
@@ -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"]
|
||||
}
|
||||
+41
@@ -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
|
||||
+100
@@ -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()
|
||||
+17
@@ -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"
|
||||
}
|
||||
}
|
||||
+101
@@ -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",
|
||||
)
|
||||
|
||||
+316
@@ -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."
|
||||
+428
@@ -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)
|
||||
+88
@@ -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
|
||||
+57
@@ -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",
|
||||
)
|
||||
+93
@@ -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",
|
||||
)
|
||||
+336
@@ -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)",
|
||||
)
|
||||
Reference in New Issue
Block a user