Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# AgentCore Identity: Private IdP (Keycloak) with Runtime

## Overview

This sample shows how to configure **AgentCore Runtime** with inbound JWT authorization from a **private Keycloak** instance hosted inside your VPC. The Keycloak OIDC discovery endpoint is not publicly accessible — AgentCore Identity reaches it via VPC Lattice using the `privateEndpoint` configuration.

### Architecture

```
Caller (Lambda/App in VPC)
│ 1. Get JWT from private Keycloak
│ 2. Authorization: Bearer <Keycloak JWT>
AgentCore Runtime
│ Validates JWT signature via JWKS
AgentCore Identity ──VPC Lattice──▶ Internal ALB (ACM cert) ──▶ Keycloak EC2
(privateEndpoint)
```

### Tutorial Details

| Information | Details |
|-------------|---------|
| Tutorial type | CLI walkthrough |
| Agent type | Single |
| Agentic Framework | Strands Agents |
| AWS Services | AgentCore Runtime, VPC Lattice, ALB, ACM, EC2, Route53 |
| Identity Provider | Keycloak 26 (self-hosted, private VPC) |
| Auth type | Inbound JWT (client_credentials grant) |
| Estimated time | 30 minutes |
| Estimated cost | ~$75/month (VPC Lattice + EC2 + ALB) |

## Prerequisites

- AWS CLI ≥ 2.34.37 (`aws --version`)
- A Route53 hosted zone you control (for ACM DNS validation)
- A VPC with at least 2 subnets in different AZs
- Python 3.11+
- AgentCore CLI (`pip install bedrock-agentcore`)

## Step 1: Deploy Keycloak Infrastructure

Deploy the shared CloudFormation template:

```bash
aws cloudformation deploy \
--template-file ../shared-keycloak-infra/keycloak-infra.yaml \
--stack-name keycloak-private-idp \
--parameter-overrides \
DomainName=keycloak.your-domain.example.com \
HostedZoneId=Z0123456789 \
VpcId=vpc-0abc123 \
SubnetIds=subnet-0abc123,subnet-0def456 \
KeycloakAdminPassword=YourSecurePassword123 \
--capabilities CAPABILITY_IAM
```

Wait for stack completion (~5 min for ACM validation + EC2 boot).

## Step 2: Configure Keycloak

```bash
# Wait for Keycloak to boot and configure realm + client
python ../shared-keycloak-infra/setup_keycloak.py \
--url http://<EC2-private-IP>:8080 \
--password YourSecurePassword123
```

> **Note**: Run this from within the VPC (e.g., via SSM or a bastion) since Keycloak is only accessible internally.

## Step 3: Create AgentCore Runtime with privateEndpoint

```bash
aws bedrock-agentcore-control create-agent-runtime \
--cli-input-json '{
"agentRuntimeName": "private_keycloak_demo",
"agentRuntimeArtifact": {
"containerConfiguration": {
"containerUri": "<YOUR_ECR_URI>:latest"
}
},
"roleArn": "arn:aws:iam::<ACCOUNT>:role/AgentCoreRuntimeRole",
"networkConfiguration": {"networkMode": "PUBLIC"},
"authorizerConfiguration": {
"customJWTAuthorizer": {
"discoveryUrl": "https://keycloak.your-domain.example.com/realms/orion/.well-known/openid-configuration",
"allowedClients": ["content-export-adapter"],
"allowedAudience": ["account"],
"privateEndpoint": {
"managedVpcResource": {
"vpcIdentifier": "vpc-0abc123",
"subnetIds": ["subnet-0abc123", "subnet-0def456"],
"endpointIpAddressType": "IPV4",
"securityGroupIds": ["sg-0abc123"]
}
}
}
},
"protocolConfiguration": {"serverProtocol": "HTTP"}
}' \
--region us-east-1
```

Wait for status `READY` (~5 min for VPC Lattice provisioning):

```bash
aws bedrock-agentcore-control get-agent-runtime \
--agent-runtime-id <RUNTIME_ID> \
--query 'status'
```

## Step 4: Test Invocation

```bash
python invoke.py \
--keycloak-url https://keycloak.your-domain.example.com \
--client-id content-export-adapter \
--client-secret test-secret-12345 \
--runtime-id <RUNTIME_ID>
```

## Cleanup

```bash
aws bedrock-agentcore-control delete-agent-runtime --agent-runtime-id <RUNTIME_ID>
aws cloudformation delete-stack --stack-name keycloak-private-idp
```

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| "OIDC discovery endpoint is not valid" | Missing `privateEndpoint` | Add the `privateEndpoint` block |
| CREATE_FAILED | Self-signed cert or SG blocking | Use ACM cert; allow VPC CIDR on 443 |
| "Invalid inbound token" | Issuer mismatch | Set `KC_HOSTNAME` to match discovery URL |
| "insufficient_scope" | Audience mismatch | Set `allowedAudience` to match token's `aud` |
| CLI error "Unknown parameter privateEndpoint" | CLI too old | Upgrade to ≥ 2.34.37 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Minimal AgentCore Runtime agent for private IdP demo."""

from bedrock_agentcore.runtime import App

app = App()


@app.handler
def handle(prompt: str, **kwargs) -> str:
return f"Echo from private-IdP-secured agent: {prompt}"


if __name__ == "__main__":
app.run()
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/bash
# Cleanup Private Keycloak IdP + AgentCore Runtime sample
# Usage: ./cleanup_sample.sh <RUNTIME_ID>
set -e

RUNTIME_ID=${1:?Usage: ./cleanup_sample.sh RUNTIME_ID}
STACK_NAME="keycloak-private-idp"
REGION=${AWS_DEFAULT_REGION:-us-east-1}

echo "=== Deleting AgentCore Runtime ==="
aws bedrock-agentcore-control delete-agent-runtime \
--agent-runtime-id "$RUNTIME_ID" \
--region "$REGION" 2>/dev/null && echo "Runtime deletion initiated" || echo "Runtime not found"

echo ""
echo "=== Deleting CloudFormation stack ==="
aws cloudformation delete-stack --stack-name "$STACK_NAME" --region "$REGION"
echo "Stack deletion initiated. Waiting..."
aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" --region "$REGION" 2>/dev/null || true

echo ""
echo "✅ Cleanup complete"
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/bin/bash
# Deploy Private Keycloak IdP + AgentCore Runtime sample
# Usage: ./deploy_sample.sh <DOMAIN> <HOSTED_ZONE_ID> <VPC_ID> <SUBNET_1> <SUBNET_2> <KC_PASSWORD>
set -e

DOMAIN=${1:?Usage: ./deploy_sample.sh DOMAIN HOSTED_ZONE_ID VPC_ID SUBNET_1 SUBNET_2 KC_PASSWORD}
HOSTED_ZONE_ID=${2:?}
VPC_ID=${3:?}
SUBNET_1=${4:?}
SUBNET_2=${5:?}
KC_PASSWORD=${6:?}
STACK_NAME="keycloak-private-idp"
REGION=${AWS_DEFAULT_REGION:-us-east-1}

echo "=== Step 1: Deploy Keycloak infrastructure ==="
aws cloudformation deploy \
--template-file keycloak-infra.yaml \
--stack-name "$STACK_NAME" \
--parameter-overrides \
DomainName="$DOMAIN" \
HostedZoneId="$HOSTED_ZONE_ID" \
VpcId="$VPC_ID" \
SubnetIds="$SUBNET_1,$SUBNET_2" \
KeycloakAdminPassword="$KC_PASSWORD" \
--capabilities CAPABILITY_IAM \
--region "$REGION"

echo ""
echo "=== Step 2: Get stack outputs ==="
INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \
--query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' --output text)
SG_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \
--query 'Stacks[0].Outputs[?OutputKey==`SecurityGroupId`].OutputValue' --output text)
DISCOVERY_URL=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \
--query 'Stacks[0].Outputs[?OutputKey==`DiscoveryUrl`].OutputValue' --output text)

echo "Instance: $INSTANCE_ID"
echo "Discovery URL: $DISCOVERY_URL"

echo ""
echo "=== Step 3: Configure Keycloak (via SSM) ==="
PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --region "$REGION" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)

# Wait for Keycloak to boot then configure
CMD_ID=$(aws ssm send-command --instance-ids "$INSTANCE_ID" --document-name "AWS-RunShellScript" \
--parameters "{\"commands\":[\"sleep 90\",\"python3 -c \\\"import urllib.request,json; urllib.request.urlopen('http://127.0.0.1:8080/realms/master')\\\" && echo READY\"]}" \
--region "$REGION" --query 'Command.CommandId' --output text)
echo "Waiting for Keycloak boot..."
aws ssm wait command-executed --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --region "$REGION" 2>/dev/null || sleep 120

python3 setup_keycloak.py --url "http://$PRIVATE_IP:8080" --password "$KC_PASSWORD"

echo ""
echo "=== Step 4: Create AgentCore Runtime ==="
RUNTIME_RESULT=$(aws bedrock-agentcore-control create-agent-runtime --cli-input-json "{
\"agentRuntimeName\": \"private_keycloak_runtime\",
\"agentRuntimeArtifact\": {\"containerConfiguration\": {\"containerUri\": \"$(aws sts get-caller-identity --query Account --output text).dkr.ecr.$REGION.amazonaws.com/agentcore-echo:latest\"}},
\"roleArn\": \"arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/AgentCoreRuntimeRole\",
\"networkConfiguration\": {\"networkMode\": \"PUBLIC\"},
\"authorizerConfiguration\": {
\"customJWTAuthorizer\": {
\"discoveryUrl\": \"$DISCOVERY_URL\",
\"allowedClients\": [\"content-export-adapter\"],
\"allowedAudience\": [\"account\"],
\"privateEndpoint\": {
\"managedVpcResource\": {
\"vpcIdentifier\": \"$VPC_ID\",
\"subnetIds\": [\"$SUBNET_1\", \"$SUBNET_2\"],
\"endpointIpAddressType\": \"IPV4\",
\"securityGroupIds\": [\"$SG_ID\"]
}
}
}
},
\"protocolConfiguration\": {\"serverProtocol\": \"HTTP\"}
}" --region "$REGION" --output json)

RUNTIME_ID=$(echo "$RUNTIME_RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin)['agentRuntimeId'])")
echo "Runtime ID: $RUNTIME_ID"
echo "Waiting for READY status..."

for i in $(seq 1 20); do
STATUS=$(aws bedrock-agentcore-control get-agent-runtime --agent-runtime-id "$RUNTIME_ID" --region "$REGION" --query 'status' --output text)
[ "$STATUS" = "READY" ] && break
sleep 30
done

echo ""
echo "============================================"
echo "✅ Deployment complete!"
echo " Runtime ID: $RUNTIME_ID"
echo " Runtime Status: $STATUS"
echo " Discovery URL: $DISCOVERY_URL"
echo " Keycloak Admin: https://$DOMAIN/admin"
echo "============================================"
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Invoke AgentCore Runtime with a JWT from a private Keycloak instance.
"""

import argparse
import json
import urllib.request
import urllib.parse


def get_keycloak_token(keycloak_url: str, client_id: str, client_secret: str,
realm: str = "orion") -> str:
"""Get a client_credentials JWT from Keycloak."""
data = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}).encode()
req = urllib.request.Request(
f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp = json.loads(urllib.request.urlopen(req).read())
return resp["access_token"]


def invoke_runtime(runtime_arn: str, token: str, prompt: str, region: str = "us-east-1"):
"""Invoke AgentCore Runtime with Bearer token."""
encoded_arn = urllib.parse.quote(runtime_arn, safe="")
url = f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded_arn}/invocations?qualifier=DEFAULT"

payload = json.dumps({"prompt": prompt}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Content-Type", "application/json")

resp = urllib.request.urlopen(req)
print(f"Status: {resp.status}")
print(f"Response: {resp.read().decode()}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Invoke AgentCore Runtime with Keycloak JWT")
parser.add_argument("--keycloak-url", required=True, help="Keycloak base URL")
parser.add_argument("--client-id", default="content-export-adapter")
parser.add_argument("--client-secret", default="test-secret-12345")
parser.add_argument("--runtime-id", required=True, help="AgentCore Runtime ID")
parser.add_argument("--region", default="us-east-1")
parser.add_argument("--prompt", default="Hello, what can you do?")
args = parser.parse_args()

account_id = input("AWS Account ID: ") if not args.runtime_id.startswith("arn:") else ""
runtime_arn = (
args.runtime_id if args.runtime_id.startswith("arn:")
else f"arn:aws:bedrock-agentcore:{args.region}:{account_id}:runtime/{args.runtime_id}"
)

print("Getting Keycloak token...")
token = get_keycloak_token(args.keycloak_url, args.client_id, args.client_secret)
print(f"✅ Token obtained ({len(token)} chars)")

print(f"\nInvoking runtime: {args.runtime_id}")
invoke_runtime(runtime_arn, token, args.prompt, args.region)
Loading
Loading