diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/README.md b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/README.md new file mode 100644 index 000000000..932a6ea29 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/README.md @@ -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 + ▼ +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://: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": ":latest" + } + }, + "roleArn": "arn:aws:iam:::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 \ + --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 +``` + +## Cleanup + +```bash +aws bedrock-agentcore-control delete-agent-runtime --agent-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 | diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/agent.py b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/agent.py new file mode 100644 index 000000000..29e892c1b --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/agent.py @@ -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() diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/cleanup_sample.sh b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/cleanup_sample.sh new file mode 100755 index 000000000..819825cd7 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/cleanup_sample.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Cleanup Private Keycloak IdP + AgentCore Runtime sample +# Usage: ./cleanup_sample.sh +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" diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/deploy_sample.sh b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/deploy_sample.sh new file mode 100755 index 000000000..ba0aa006c --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/deploy_sample.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Deploy Private Keycloak IdP + AgentCore Runtime sample +# Usage: ./deploy_sample.sh +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 "============================================" diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/invoke.py b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/invoke.py new file mode 100644 index 000000000..e78896505 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/invoke.py @@ -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) diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/keycloak-infra.yaml b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/keycloak-infra.yaml new file mode 100644 index 000000000..0a259cef6 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/keycloak-infra.yaml @@ -0,0 +1,159 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: > + Private Keycloak IdP infrastructure for AgentCore Identity samples. + Deploys Keycloak on EC2 behind an internal ALB with ACM certificate. + +Parameters: + DomainName: + Type: String + Description: FQDN for Keycloak (e.g., keycloak.example.people.aws.dev) + HostedZoneId: + Type: AWS::Route53::HostedZone::Id + Description: Route53 hosted zone ID for DNS validation and alias record + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC where Keycloak and ALB will be deployed + SubnetIds: + Type: List + Description: At least 2 subnets in different AZs (for ALB) + KeycloakAdminPassword: + Type: String + NoEcho: true + MinLength: 12 + Description: Keycloak admin password + +Resources: + SecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Keycloak EC2 + ALB + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 8080 + ToPort: 8080 + CidrIp: 10.0.0.0/8 + Description: Keycloak HTTP from VPC + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 10.0.0.0/8 + Description: ALB HTTPS from VPC + + InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + + InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref InstanceRole] + + KeycloakInstance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}' + InstanceType: t3.medium + SubnetId: !Select [0, !Ref SubnetIds] + SecurityGroupIds: [!Ref SecurityGroup] + IamInstanceProfile: !Ref InstanceProfile + UserData: + Fn::Base64: !Sub | + #!/bin/bash + yum install -y docker + systemctl enable docker && systemctl start docker + docker run -d --name keycloak --restart always \ + -p 8080:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=${KeycloakAdminPassword} \ + -e KC_HOSTNAME=https://${DomainName} \ + -e KC_HOSTNAME_STRICT=false \ + -e KC_HTTP_ENABLED=true \ + -e KC_PROXY_HEADERS=xforwarded \ + quay.io/keycloak/keycloak:26.0 start-dev + Tags: + - Key: Name + Value: keycloak-private-idp + + Certificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: !Ref DomainName + ValidationMethod: DNS + DomainValidationOptions: + - DomainName: !Ref DomainName + HostedZoneId: !Ref HostedZoneId + + TargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + Protocol: HTTP + Port: 8080 + VpcId: !Ref VpcId + TargetType: instance + Targets: + - Id: !Ref KeycloakInstance + HealthCheckPath: /realms/master + HealthCheckIntervalSeconds: 30 + HealthyThresholdCount: 2 + UnhealthyThresholdCount: 3 + + ALB: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + # checkov:skip=CKV_AWS_91:Access logging omitted for tutorial simplicity + Properties: + Scheme: internal + Subnets: !Ref SubnetIds + SecurityGroups: [!Ref SecurityGroup] + LoadBalancerAttributes: + - Key: routing.http.drop_invalid_header_fields.enabled + Value: 'true' + - Key: access_logs.s3.enabled + Value: 'false' + + Listener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + LoadBalancerArn: !Ref ALB + Protocol: HTTPS + Port: 443 + SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06 + Certificates: + - CertificateArn: !Ref Certificate + DefaultActions: + - Type: forward + TargetGroupArn: !Ref TargetGroup + + DNSRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + AliasTarget: + HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID + DNSName: !GetAtt ALB.DNSName + +Outputs: + DiscoveryUrl: + Value: !Sub 'https://${DomainName}/realms/orion/.well-known/openid-configuration' + Description: OIDC discovery URL for AgentCore customJWTAuthorizer + KeycloakUrl: + Value: !Sub 'https://${DomainName}' + VpcId: + Value: !Ref VpcId + SubnetIds: + Value: !Join [',', !Ref SubnetIds] + SecurityGroupId: + Value: !Ref SecurityGroup + InstanceId: + Value: !Ref KeycloakInstance diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/requirements.txt b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/requirements.txt new file mode 100644 index 000000000..3c3195e14 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/requirements.txt @@ -0,0 +1 @@ +bedrock-agentcore>=1.0.0 diff --git a/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/setup_keycloak.py b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/setup_keycloak.py new file mode 100644 index 000000000..089f9ca4e --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/13-Private-IdP-Keycloak-Runtime/setup_keycloak.py @@ -0,0 +1,121 @@ +""" +Setup script for Keycloak realm and client configuration. +Waits for Keycloak to boot, then configures: + - Disables SSL requirement on master realm + - Creates 'orion' realm with SSL disabled + - Creates 'content-export-adapter' client with client_credentials grant +""" + +import argparse +import json +import time +import urllib.request +import urllib.error +import urllib.parse + + +def wait_for_keycloak(base_url: str, timeout: int = 300): + """Wait for Keycloak to be ready.""" + print(f"Waiting for Keycloak at {base_url}...") + start = time.time() + while time.time() - start < timeout: + try: + req = urllib.request.Request(f"{base_url}/realms/master") + urllib.request.urlopen(req, timeout=5) + print("✅ Keycloak is ready") + return + except (urllib.error.URLError, OSError): + time.sleep(5) + raise TimeoutError(f"Keycloak not ready after {timeout}s") + + +def get_admin_token(base_url: str, password: str) -> str: + """Get admin access token.""" + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": "admin-cli", + "username": "admin", + "password": password, + }).encode() + req = urllib.request.Request( + f"{base_url}/realms/master/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 api_call(base_url: str, token: str, method: str, path: str, body: dict = None): + """Make an authenticated API call to Keycloak Admin REST API.""" + url = f"{base_url}/admin{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + return urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + if e.code == 409: # Conflict = already exists + return None + raise + + +def setup_keycloak(base_url: str, password: str, realm: str = "orion", + client_id: str = "content-export-adapter", + client_secret: str = "test-secret-12345"): + """Configure Keycloak with realm and client.""" + wait_for_keycloak(base_url) + token = get_admin_token(base_url, password) + print(f"✅ Admin token obtained") + + # Disable SSL on master realm + api_call(base_url, token, "PUT", "/realms/master", {"sslRequired": "none"}) + print("✅ Master realm SSL disabled") + + # Create realm + api_call(base_url, token, "POST", "/realms", { + "realm": realm, + "enabled": True, + "sslRequired": "none", + }) + print(f"✅ Realm '{realm}' created") + + # Create client + api_call(base_url, token, "POST", f"/realms/{realm}/clients", { + "clientId": client_id, + "enabled": True, + "serviceAccountsEnabled": True, + "clientAuthenticatorType": "client-secret", + "secret": client_secret, + "directAccessGrantsEnabled": True, + "publicClient": False, + }) + print(f"✅ Client '{client_id}' created (secret: {client_secret})") + + # Verify token endpoint works + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }).encode() + req = urllib.request.Request( + f"{base_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()) + print(f"✅ Token endpoint verified (token length: {len(resp['access_token'])})") + print(f"\nDiscovery URL: {base_url}/realms/{realm}/.well-known/openid-configuration") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Configure Keycloak for AgentCore") + parser.add_argument("--url", required=True, help="Keycloak base URL (e.g., http://localhost:8080)") + parser.add_argument("--password", required=True, help="Keycloak admin password") + parser.add_argument("--realm", default="orion", help="Realm name (default: orion)") + parser.add_argument("--client-id", default="content-export-adapter", help="Client ID") + parser.add_argument("--client-secret", default="test-secret-12345", help="Client secret") + args = parser.parse_args() + + setup_keycloak(args.url, args.password, args.realm, args.client_id, args.client_secret) diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/README.md b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/README.md new file mode 100644 index 000000000..aeb33badb --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/README.md @@ -0,0 +1,135 @@ +# AgentCore Identity: Private IdP (Keycloak) with Gateway + +## Overview + +This sample shows how to configure **AgentCore Gateway** with inbound JWT authorization from a **private Keycloak** instance, plus a Lambda-backed MCP tool target. Callers authenticate with a Keycloak JWT, and the Gateway routes MCP `tools/call` requests to a Lambda function. + +### Architecture + +``` +Caller + │ 1. Get JWT from private Keycloak + │ 2. POST /mcp with Authorization: Bearer + ▼ +AgentCore Gateway (MCP protocol) + │ + │ Validates JWT via JWKS + ▼ +AgentCore Identity ──VPC Lattice──▶ Internal ALB (ACM cert) ──▶ Keycloak EC2 + │ (privateEndpoint) + │ Routes tools/call + ▼ +Lambda (ban-appeal-tools) + │ + ▼ +Response: enforcement status / appeal confirmation +``` + +### Tutorial Details + +| Information | Details | +|-------------|---------| +| Tutorial type | CLI walkthrough | +| Agent type | Gateway + Lambda tools | +| AWS Services | AgentCore Gateway, VPC Lattice, ALB, ACM, EC2, Lambda, 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 +- Keycloak deployed via `../shared-keycloak-infra/` (see Runtime sample Step 1-2) +- Python 3.11+ + +## Step 1: Deploy Keycloak (if not already done) + +Follow Steps 1-2 from `../13-Private-IdP-Keycloak-Runtime/README.md`. + +## Step 2: Create the Lambda Tool + +```bash +cd lambda/ +zip ban_appeal.zip ban_appeal.py + +aws lambda create-function \ + --function-name ban-appeal-tools \ + --runtime python3.12 \ + --handler ban_appeal.handler \ + --role arn:aws:iam:::role/ \ + --zip-file fileb://ban_appeal.zip + +aws lambda add-permission \ + --function-name ban-appeal-tools \ + --statement-id agentcore \ + --action lambda:InvokeFunction \ + --principal bedrock-agentcore.amazonaws.com +``` + +## Step 3: Create Gateway with privateEndpoint + +```bash +aws bedrock-agentcore-control create-gateway \ + --cli-input-json '{ + "name": "private-keycloak-gw", + "roleArn": "arn:aws:iam:::role/AgentCoreGatewayRole", + "protocolType": "MCP", + "authorizerType": "CUSTOM_JWT", + "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"] + } + } + } + } + }' \ + --region us-east-1 +``` + +## Step 4: Register Lambda as Gateway Target + +```bash +aws bedrock-agentcore-control create-gateway-target \ + --gateway-identifier \ + --name ban-appeal-tools \ + --target-configuration '{ + "mcp": { + "lambda": { + "lambdaArn": "arn:aws:lambda:us-east-1::function:ban-appeal-tools", + "toolSchema": { + "inlinePayload": [ + {"name": "check_enforcement_status", "description": "Check player ban status", "inputSchema": {"type": "object", "properties": {"player_id": {"type": "string", "description": "Player ID"}}, "required": ["player_id"]}}, + {"name": "submit_appeal", "description": "Submit a ban appeal", "inputSchema": {"type": "object", "properties": {"player_id": {"type": "string"}, "reason": {"type": "string"}}, "required": ["player_id", "reason"]}} + ] + } + } + } + }' \ + --credential-provider-configurations '[{"credentialProviderType": "GATEWAY_IAM_ROLE"}]' +``` + +## Step 5: Test + +```bash +python invoke.py \ + --keycloak-url https://keycloak.your-domain.example.com \ + --gateway-url https://.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp +``` + +## Cleanup + +```bash +aws bedrock-agentcore-control delete-gateway-target --gateway-identifier --target-id +aws bedrock-agentcore-control delete-gateway --gateway-id +aws lambda delete-function --function-name ban-appeal-tools +aws cloudformation delete-stack --stack-name keycloak-private-idp +``` diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/cleanup_sample.sh b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/cleanup_sample.sh new file mode 100755 index 000000000..5d9a1169e --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/cleanup_sample.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Cleanup Private Keycloak IdP + AgentCore Gateway sample +# Usage: ./cleanup_sample.sh +set -e + +GW_ID=${1:?Usage: ./cleanup_sample.sh GATEWAY_ID} +STACK_NAME="keycloak-private-idp-gw" +REGION=${AWS_DEFAULT_REGION:-us-east-1} + +echo "=== Deleting Gateway targets ===" +for TARGET_ID in $(aws bedrock-agentcore-control list-gateway-targets --gateway-identifier "$GW_ID" --region "$REGION" --query 'targets[*].targetId' --output text 2>/dev/null); do + aws bedrock-agentcore-control delete-gateway-target --gateway-identifier "$GW_ID" --target-id "$TARGET_ID" --region "$REGION" + echo "Deleted target: $TARGET_ID" +done + +echo "" +echo "=== Deleting Gateway ===" +aws bedrock-agentcore-control delete-gateway --gateway-id "$GW_ID" --region "$REGION" 2>/dev/null && echo "Gateway deletion initiated" || echo "Gateway not found" + +echo "" +echo "=== Deleting Lambda ===" +aws lambda delete-function --function-name ban-appeal-tools --region "$REGION" 2>/dev/null || true + +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" diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/deploy_sample.sh b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/deploy_sample.sh new file mode 100755 index 000000000..eb9f34f7b --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/deploy_sample.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Deploy Private Keycloak IdP + AgentCore Gateway sample +# Usage: ./deploy_sample.sh +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-gw" +REGION=${AWS_DEFAULT_REGION:-us-east-1} +ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + +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" + +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) +INSTANCE_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$REGION" \ + --query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' --output text) +PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --region "$REGION" \ + --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) + +echo "Discovery URL: $DISCOVERY_URL" + +echo "" +echo "=== Step 2: Configure Keycloak ===" +echo "Waiting for boot..." +sleep 120 +python3 setup_keycloak.py --url "http://$PRIVATE_IP:8080" --password "$KC_PASSWORD" + +echo "" +echo "=== Step 3: Create Lambda tool ===" +cd lambda && zip -j ban_appeal.zip ban_appeal.py && cd .. +aws lambda create-function \ + --function-name ban-appeal-tools \ + --runtime python3.12 \ + --handler ban_appeal.handler \ + --role "arn:aws:iam::$ACCOUNT_ID:role/AgentCoreRuntimeRole" \ + --zip-file fileb://lambda/ban_appeal.zip \ + --region "$REGION" --output text --query 'FunctionArn' 2>/dev/null || echo "Lambda exists" +aws lambda add-permission --function-name ban-appeal-tools --statement-id agentcore \ + --action lambda:InvokeFunction --principal bedrock-agentcore.amazonaws.com \ + --region "$REGION" 2>/dev/null || true +LAMBDA_ARN="arn:aws:lambda:$REGION:$ACCOUNT_ID:function:ban-appeal-tools" + +echo "" +echo "=== Step 4: Create Gateway ===" +GW_RESULT=$(aws bedrock-agentcore-control create-gateway --cli-input-json "{ + \"name\": \"private-keycloak-gw\", + \"roleArn\": \"arn:aws:iam::$ACCOUNT_ID:role/AgentCoreRuntimeRole\", + \"protocolType\": \"MCP\", + \"authorizerType\": \"CUSTOM_JWT\", + \"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\"] + } + } + } + } +}" --region "$REGION" --output json) + +GW_ID=$(echo "$GW_RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin)['gatewayId'])") +GW_URL=$(echo "$GW_RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin)['gatewayUrl'])") +echo "Gateway ID: $GW_ID" +echo "Waiting for READY..." + +for i in $(seq 1 20); do + STATUS=$(aws bedrock-agentcore-control get-gateway --gateway-id "$GW_ID" --region "$REGION" --query 'status' --output text) + [ "$STATUS" = "READY" ] && break + sleep 30 +done + +echo "" +echo "=== Step 5: Register Lambda target ===" +aws bedrock-agentcore-control create-gateway-target \ + --gateway-identifier "$GW_ID" \ + --name ban-appeal-tools \ + --target-configuration "{\"mcp\":{\"lambda\":{\"lambdaArn\":\"$LAMBDA_ARN\",\"toolSchema\":{\"inlinePayload\":[{\"name\":\"check_enforcement_status\",\"description\":\"Check player ban status\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"player_id\":{\"type\":\"string\",\"description\":\"Player ID\"}},\"required\":[\"player_id\"]}},{\"name\":\"submit_appeal\",\"description\":\"Submit a ban appeal\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"player_id\":{\"type\":\"string\"},\"reason\":{\"type\":\"string\"}},\"required\":[\"player_id\",\"reason\"]}}]}}}}" \ + --credential-provider-configurations '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]' \ + --region "$REGION" --output text --query 'targetId' + +echo "" +echo "============================================" +echo "✅ Deployment complete!" +echo " Gateway ID: $GW_ID" +echo " Gateway URL: $GW_URL" +echo " Gateway Status: $STATUS" +echo " Discovery URL: $DISCOVERY_URL" +echo "============================================" +echo "" +echo "Test: python3 invoke.py --keycloak-url https://$DOMAIN --gateway-url $GW_URL" diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/invoke.py b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/invoke.py new file mode 100644 index 000000000..9ecd65bcc --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/invoke.py @@ -0,0 +1,73 @@ +""" +Invoke AgentCore Gateway MCP tools 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 mcp_call(gateway_url: str, token: str, method: str, params: dict = None, req_id: int = 1): + """Make an MCP JSON-RPC call to the gateway.""" + payload = json.dumps({ + "jsonrpc": "2.0", + "method": method, + "id": req_id, + **({"params": params} if params else {}), + }).encode() + req = urllib.request.Request(gateway_url, data=payload, method="POST") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + resp = json.loads(urllib.request.urlopen(req).read()) + return resp + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Invoke AgentCore Gateway with Keycloak JWT") + parser.add_argument("--keycloak-url", required=True, help="Keycloak base URL") + parser.add_argument("--gateway-url", required=True, help="Gateway MCP endpoint URL") + parser.add_argument("--client-id", default="content-export-adapter") + parser.add_argument("--client-secret", default="test-secret-12345") + args = parser.parse_args() + + print("Getting Keycloak token...") + token = get_keycloak_token(args.keycloak_url, args.client_id, args.client_secret) + print(f"✅ Token obtained ({len(token)} chars)\n") + + print("=== tools/list ===") + result = mcp_call(args.gateway_url, token, "tools/list") + tools = result.get("result", {}).get("tools", []) + for t in tools: + print(f" - {t['name']}: {t['description']}") + + print("\n=== tools/call: check_enforcement_status ===") + result = mcp_call(args.gateway_url, token, "tools/call", { + "name": "ban-appeal-tools___check_enforcement_status", + "arguments": {"player_id": "1004942767660"}, + }, req_id=2) + print(f" {json.dumps(result, indent=2)}") + + print("\n=== tools/call: submit_appeal ===") + result = mcp_call(args.gateway_url, token, "tools/call", { + "name": "ban-appeal-tools___submit_appeal", + "arguments": {"player_id": "1004942767660", "reason": "First offense"}, + }, req_id=3) + print(f" {json.dumps(result, indent=2)}") diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/keycloak-infra.yaml b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/keycloak-infra.yaml new file mode 100644 index 000000000..0a259cef6 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/keycloak-infra.yaml @@ -0,0 +1,159 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: > + Private Keycloak IdP infrastructure for AgentCore Identity samples. + Deploys Keycloak on EC2 behind an internal ALB with ACM certificate. + +Parameters: + DomainName: + Type: String + Description: FQDN for Keycloak (e.g., keycloak.example.people.aws.dev) + HostedZoneId: + Type: AWS::Route53::HostedZone::Id + Description: Route53 hosted zone ID for DNS validation and alias record + VpcId: + Type: AWS::EC2::VPC::Id + Description: VPC where Keycloak and ALB will be deployed + SubnetIds: + Type: List + Description: At least 2 subnets in different AZs (for ALB) + KeycloakAdminPassword: + Type: String + NoEcho: true + MinLength: 12 + Description: Keycloak admin password + +Resources: + SecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Keycloak EC2 + ALB + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 8080 + ToPort: 8080 + CidrIp: 10.0.0.0/8 + Description: Keycloak HTTP from VPC + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 10.0.0.0/8 + Description: ALB HTTPS from VPC + + InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + + InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref InstanceRole] + + KeycloakInstance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Sub '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}' + InstanceType: t3.medium + SubnetId: !Select [0, !Ref SubnetIds] + SecurityGroupIds: [!Ref SecurityGroup] + IamInstanceProfile: !Ref InstanceProfile + UserData: + Fn::Base64: !Sub | + #!/bin/bash + yum install -y docker + systemctl enable docker && systemctl start docker + docker run -d --name keycloak --restart always \ + -p 8080:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=${KeycloakAdminPassword} \ + -e KC_HOSTNAME=https://${DomainName} \ + -e KC_HOSTNAME_STRICT=false \ + -e KC_HTTP_ENABLED=true \ + -e KC_PROXY_HEADERS=xforwarded \ + quay.io/keycloak/keycloak:26.0 start-dev + Tags: + - Key: Name + Value: keycloak-private-idp + + Certificate: + Type: AWS::CertificateManager::Certificate + Properties: + DomainName: !Ref DomainName + ValidationMethod: DNS + DomainValidationOptions: + - DomainName: !Ref DomainName + HostedZoneId: !Ref HostedZoneId + + TargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + Protocol: HTTP + Port: 8080 + VpcId: !Ref VpcId + TargetType: instance + Targets: + - Id: !Ref KeycloakInstance + HealthCheckPath: /realms/master + HealthCheckIntervalSeconds: 30 + HealthyThresholdCount: 2 + UnhealthyThresholdCount: 3 + + ALB: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + # checkov:skip=CKV_AWS_91:Access logging omitted for tutorial simplicity + Properties: + Scheme: internal + Subnets: !Ref SubnetIds + SecurityGroups: [!Ref SecurityGroup] + LoadBalancerAttributes: + - Key: routing.http.drop_invalid_header_fields.enabled + Value: 'true' + - Key: access_logs.s3.enabled + Value: 'false' + + Listener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + LoadBalancerArn: !Ref ALB + Protocol: HTTPS + Port: 443 + SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06 + Certificates: + - CertificateArn: !Ref Certificate + DefaultActions: + - Type: forward + TargetGroupArn: !Ref TargetGroup + + DNSRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + AliasTarget: + HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID + DNSName: !GetAtt ALB.DNSName + +Outputs: + DiscoveryUrl: + Value: !Sub 'https://${DomainName}/realms/orion/.well-known/openid-configuration' + Description: OIDC discovery URL for AgentCore customJWTAuthorizer + KeycloakUrl: + Value: !Sub 'https://${DomainName}' + VpcId: + Value: !Ref VpcId + SubnetIds: + Value: !Join [',', !Ref SubnetIds] + SecurityGroupId: + Value: !Ref SecurityGroup + InstanceId: + Value: !Ref KeycloakInstance diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/lambda/ban_appeal.py b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/lambda/ban_appeal.py new file mode 100644 index 000000000..743a68930 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/lambda/ban_appeal.py @@ -0,0 +1,30 @@ +""" +Ban appeal tools Lambda for AgentCore Gateway. +AgentCore Gateway invokes this with the tool arguments directly. +""" + +import json + + +def handler(event, context): + player_id = event.get("player_id", "unknown") + + # If 'reason' is present, it's a submit_appeal call + if "reason" in event: + return { + "appeal_id": "APL-78291", + "status": "SUBMITTED", + "player_id": player_id, + "reason": event["reason"], + "estimated_review_days": 3, + } + + # Otherwise it's check_enforcement_status + return { + "status": "BANNED", + "reason": "Cheating - aimbot detected", + "appeal_eligible": True, + "player_id": player_id, + "ban_date": "2026-04-15", + "game": "FC Madden 26", + } diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/requirements.txt b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/requirements.txt new file mode 100644 index 000000000..70a40faa7 --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/requirements.txt @@ -0,0 +1 @@ +# No external dependencies - uses stdlib urllib only diff --git a/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/setup_keycloak.py b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/setup_keycloak.py new file mode 100644 index 000000000..089f9ca4e --- /dev/null +++ b/01-tutorials/03-AgentCore-identity/14-Private-IdP-Keycloak-Gateway/setup_keycloak.py @@ -0,0 +1,121 @@ +""" +Setup script for Keycloak realm and client configuration. +Waits for Keycloak to boot, then configures: + - Disables SSL requirement on master realm + - Creates 'orion' realm with SSL disabled + - Creates 'content-export-adapter' client with client_credentials grant +""" + +import argparse +import json +import time +import urllib.request +import urllib.error +import urllib.parse + + +def wait_for_keycloak(base_url: str, timeout: int = 300): + """Wait for Keycloak to be ready.""" + print(f"Waiting for Keycloak at {base_url}...") + start = time.time() + while time.time() - start < timeout: + try: + req = urllib.request.Request(f"{base_url}/realms/master") + urllib.request.urlopen(req, timeout=5) + print("✅ Keycloak is ready") + return + except (urllib.error.URLError, OSError): + time.sleep(5) + raise TimeoutError(f"Keycloak not ready after {timeout}s") + + +def get_admin_token(base_url: str, password: str) -> str: + """Get admin access token.""" + data = urllib.parse.urlencode({ + "grant_type": "password", + "client_id": "admin-cli", + "username": "admin", + "password": password, + }).encode() + req = urllib.request.Request( + f"{base_url}/realms/master/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 api_call(base_url: str, token: str, method: str, path: str, body: dict = None): + """Make an authenticated API call to Keycloak Admin REST API.""" + url = f"{base_url}/admin{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + return urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + if e.code == 409: # Conflict = already exists + return None + raise + + +def setup_keycloak(base_url: str, password: str, realm: str = "orion", + client_id: str = "content-export-adapter", + client_secret: str = "test-secret-12345"): + """Configure Keycloak with realm and client.""" + wait_for_keycloak(base_url) + token = get_admin_token(base_url, password) + print(f"✅ Admin token obtained") + + # Disable SSL on master realm + api_call(base_url, token, "PUT", "/realms/master", {"sslRequired": "none"}) + print("✅ Master realm SSL disabled") + + # Create realm + api_call(base_url, token, "POST", "/realms", { + "realm": realm, + "enabled": True, + "sslRequired": "none", + }) + print(f"✅ Realm '{realm}' created") + + # Create client + api_call(base_url, token, "POST", f"/realms/{realm}/clients", { + "clientId": client_id, + "enabled": True, + "serviceAccountsEnabled": True, + "clientAuthenticatorType": "client-secret", + "secret": client_secret, + "directAccessGrantsEnabled": True, + "publicClient": False, + }) + print(f"✅ Client '{client_id}' created (secret: {client_secret})") + + # Verify token endpoint works + data = urllib.parse.urlencode({ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }).encode() + req = urllib.request.Request( + f"{base_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()) + print(f"✅ Token endpoint verified (token length: {len(resp['access_token'])})") + print(f"\nDiscovery URL: {base_url}/realms/{realm}/.well-known/openid-configuration") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Configure Keycloak for AgentCore") + parser.add_argument("--url", required=True, help="Keycloak base URL (e.g., http://localhost:8080)") + parser.add_argument("--password", required=True, help="Keycloak admin password") + parser.add_argument("--realm", default="orion", help="Realm name (default: orion)") + parser.add_argument("--client-id", default="content-export-adapter", help="Client ID") + parser.add_argument("--client-secret", default="test-secret-12345", help="Client secret") + args = parser.parse_args() + + setup_keycloak(args.url, args.password, args.realm, args.client_id, args.client_secret)