├── keycloak ├── spiffe-dcr-spi-1.0.0.jar ├── spiffe-svid-client-authenticator-1.0.0.jar ├── README.md ├── docker-compose.yml ├── boot_keycloak.py └── setup_keycloak.py ├── spire ├── spire-software-statements-linux ├── oidc-discovery-provider.conf ├── agent_container.conf ├── docker-compose.yml ├── spire-down.sh ├── server_container.conf ├── generate_dummy_certs.sh ├── get-svid.sh ├── spire-up.sh ├── README.md ├── test-spiffe-authentication.sh └── test-spiffe-dcr.sh ├── pyproject.toml ├── Dockerfile.setup ├── setup-entrypoint.sh ├── Makefile ├── Dockerfile ├── DOCKER.md ├── run_spire.py ├── README.md ├── k8s-deployment.yaml ├── run_keycloak.py ├── .gitignore ├── config.json └── uv.lock /keycloak/spiffe-dcr-spi-1.0.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christian-posta/keycloak-agent-identity/HEAD/keycloak/spiffe-dcr-spi-1.0.0.jar -------------------------------------------------------------------------------- /spire/spire-software-statements-linux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christian-posta/keycloak-agent-identity/HEAD/spire/spire-software-statements-linux -------------------------------------------------------------------------------- /keycloak/spiffe-svid-client-authenticator-1.0.0.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/christian-posta/keycloak-agent-identity/HEAD/keycloak/spiffe-svid-client-authenticator-1.0.0.jar -------------------------------------------------------------------------------- /keycloak/README.md: -------------------------------------------------------------------------------- 1 | You can set this up with: 2 | 3 | Get into the .venv: 4 | 5 | ```bash 6 | ROOT_PRO$ source .venv/bin/activate 7 | ``` 8 | 9 | Go to keycloak dir and run the script 10 | 11 | ```bash 12 | cd keycloak 13 | python setup_keycloak.py --config config.json --url http://localhost:8080 14 | ``` -------------------------------------------------------------------------------- /spire/oidc-discovery-provider.conf: -------------------------------------------------------------------------------- 1 | log_level = "INFO" 2 | domains = ["spire-server", "spire-oidc-discovery", "localhost"] 3 | 4 | # Use HTTP for local development (no certificates needed) 5 | insecure_addr = ":8443" 6 | allow_insecure_scheme = true 7 | 8 | server_api { 9 | address = "unix:///tmp/spire-server/private/api.sock" 10 | } 11 | 12 | health_checks {} -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "keycloak-agent-identity" 3 | version = "0.1.0" 4 | description = "Keycloak and SPIRE integration for agent-based identity management" 5 | authors = [ 6 | {name = "Your Name", email = "your.email@example.com"} 7 | ] 8 | dependencies = [ 9 | "requests>=2.25.0", 10 | ] 11 | requires-python = ">=3.8" 12 | 13 | [project.scripts] 14 | keycloak = "run_keycloak:main" 15 | spire = "run_spire:main" 16 | 17 | [build-system] 18 | requires = ["hatchling"] 19 | build-backend = "hatchling.build" 20 | 21 | [tool.hatch.build.targets.wheel] 22 | packages = ["keycloak"] 23 | 24 | [tool.uv] 25 | dev-dependencies = [] 26 | -------------------------------------------------------------------------------- /spire/agent_container.conf: -------------------------------------------------------------------------------- 1 | agent { 2 | data_dir = "/var/lib/spire/agent/.data" 3 | log_level = "DEBUG" 4 | server_address = "spire-server" 5 | server_port = "8081" 6 | socket_path ="/opt/spire/sockets/workload_api.sock" 7 | trust_bundle_path = "/etc/spire/agent/dummy_root_ca.crt" 8 | trust_domain = "example.org" 9 | admin_socket_path = "/opt/spire/admin/admin.sock" 10 | } 11 | 12 | plugins { 13 | NodeAttestor "join_token" { 14 | plugin_data { 15 | } 16 | } 17 | KeyManager "disk" { 18 | plugin_data { 19 | directory = "/var/lib/spire/agent/.data" 20 | } 21 | } 22 | WorkloadAttestor "unix" { 23 | plugin_data { 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Dockerfile.setup: -------------------------------------------------------------------------------- 1 | # Python setup container for Keycloak configuration 2 | FROM python:3.11-slim 3 | 4 | # Install system dependencies 5 | RUN apt-get update && apt-get install -y \ 6 | curl \ 7 | && rm -rf /var/lib/apt/lists/* 8 | 9 | # Install Python dependencies 10 | RUN pip install --no-cache-dir requests 11 | 12 | # Create app directory 13 | WORKDIR /app 14 | 15 | # Copy Python setup scripts 16 | COPY keycloak/setup_keycloak.py /app/ 17 | COPY keycloak/boot_keycloak.py /app/ 18 | COPY run_keycloak.py /app/ 19 | 20 | # Copy setup entrypoint script 21 | COPY setup-entrypoint.sh /app/setup-entrypoint.sh 22 | 23 | # Make setup script executable 24 | RUN chmod +x /app/setup-entrypoint.sh 25 | 26 | # Set entrypoint 27 | ENTRYPOINT ["/app/setup-entrypoint.sh"] -------------------------------------------------------------------------------- /keycloak/docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | keycloak-idp: 4 | image: quay.io/keycloak/keycloak:26.2.5 5 | environment: 6 | KC_HEALTH_ENABLED: "true" 7 | KC_BOOTSTRAP_ADMIN_USERNAME: admin 8 | KC_BOOTSTRAP_ADMIN_PASSWORD: admin 9 | KC_HTTP_ENABLED: "true" 10 | KC_HOSTNAME: "localhost" 11 | KC_HOSTNAME_PORT: "8080" 12 | KC_HOSTNAME_STRICT: "false" 13 | ports: 14 | - "8080:8080" 15 | volumes: 16 | - ./spiffe-svid-client-authenticator-1.0.0.jar:/opt/keycloak/providers/spiffe-svid-client-authenticator-1.0.0.jar:ro 17 | - ./spiffe-dcr-spi-1.0.0.jar:/opt/keycloak/providers/spiffe-dcr-spi-1.0.0.jar:ro 18 | command: start-dev 19 | networks: 20 | - keycloak-shared-network 21 | 22 | networks: 23 | keycloak-shared-network: 24 | driver: bridge -------------------------------------------------------------------------------- /spire/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | spire-server: 3 | image: ghcr.io/spiffe/spire-server:1.12.4 4 | container_name: spire-server 5 | ports: 6 | - "18081:8081" 7 | volumes: 8 | - ./server_container.conf:/etc/spire/server/server.conf:ro 9 | - ./dummy_upstream_ca.crt:/etc/spire/server/dummy_upstream_ca.crt:ro 10 | - ./dummy_upstream_ca.key:/etc/spire/server/dummy_upstream_ca.key:ro 11 | - spire-server-socket:/tmp/spire-server/private 12 | - ./spire-software-statements-linux:/opt/spire/plugins/spire-software-statements:ro 13 | command: ["-config", "/etc/spire/server/server.conf"] 14 | networks: 15 | - keycloak_keycloak-shared-network 16 | 17 | spire-oidc-discovery: 18 | image: ghcr.io/spiffe/oidc-discovery-provider:1.12.4 19 | container_name: spire-oidc-discovery 20 | depends_on: 21 | - spire-server 22 | ports: 23 | - "18443:8443" 24 | volumes: 25 | - ./oidc-discovery-provider.conf:/opt/spire/conf/oidc-discovery-provider.conf:ro 26 | - spire-server-socket:/tmp/spire-server/private:ro 27 | working_dir: /opt/spire/conf 28 | command: ["-config", "oidc-discovery-provider.conf"] 29 | networks: 30 | - keycloak_keycloak-shared-network 31 | 32 | spire-agent: 33 | image: ghcr.io/spiffe/spire-agent:1.12.4 34 | container_name: spire-agent 35 | depends_on: 36 | - spire-server 37 | volumes: 38 | - ./agent_container.conf:/etc/spire/agent/agent.conf:ro 39 | - ./dummy_root_ca.crt:/etc/spire/agent/dummy_root_ca.crt:ro 40 | networks: 41 | - keycloak_keycloak-shared-network 42 | 43 | volumes: 44 | spire-server-socket: 45 | 46 | networks: 47 | keycloak_keycloak-shared-network: 48 | external: true 49 | 50 | -------------------------------------------------------------------------------- /setup-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Default values 5 | KEYCLOAK_URL=${KEYCLOAK_URL:-"http://localhost:8080"} 6 | CONFIG_FILE=${CONFIG_FILE:-"/app/config.json"} 7 | ADMIN_USERNAME=${ADMIN_USERNAME:-"admin"} 8 | ADMIN_PASSWORD=${ADMIN_PASSWORD:-"admin"} 9 | MAX_RETRIES=${MAX_RETRIES:-30} 10 | 11 | echo "🔧 Keycloak Setup Init Container" 12 | echo "Keycloak URL: $KEYCLOAK_URL" 13 | echo "Config file: $CONFIG_FILE" 14 | 15 | # Wait for Keycloak to be ready 16 | echo "⏳ Waiting for Keycloak to be ready..." 17 | for i in $(seq 1 $MAX_RETRIES); do 18 | if curl -s -f "$KEYCLOAK_URL/realms/master" > /dev/null 2>&1; then 19 | echo "✅ Keycloak is ready!" 20 | break 21 | fi 22 | echo "Attempt $i/$MAX_RETRIES - Keycloak not ready yet..." 23 | sleep 2 24 | done 25 | 26 | # Final check 27 | if ! curl -s -f "$KEYCLOAK_URL/realms/master" > /dev/null 2>&1; then 28 | echo "❌ Keycloak failed to start within expected time" 29 | exit 1 30 | fi 31 | 32 | echo "🔎 Environment variables:" 33 | echo " KEYCLOAK_URL: $KEYCLOAK_URL" 34 | echo " CONFIG_FILE: $CONFIG_FILE" 35 | echo " ADMIN_USERNAME: $ADMIN_USERNAME" 36 | echo " ADMIN_PASSWORD: $ADMIN_PASSWORD" 37 | echo " MAX_RETRIES: $MAX_RETRIES" 38 | 39 | echo "🔎 Config file contents ($CONFIG_FILE):" 40 | if [ -f "$CONFIG_FILE" ]; then 41 | cat "$CONFIG_FILE" 42 | else 43 | echo "❌ Config file not found: $CONFIG_FILE" 44 | exit 1 45 | fi 46 | 47 | # Run setup 48 | echo "🔧 Running Keycloak setup..." 49 | python3 /app/setup_keycloak.py \ 50 | --config "$CONFIG_FILE" \ 51 | --url "$KEYCLOAK_URL" \ 52 | --admin-user "$ADMIN_USERNAME" \ 53 | --admin-pass "$ADMIN_PASSWORD" \ 54 | --summary 55 | 56 | if [ $? -eq 0 ]; then 57 | echo "✅ Keycloak setup completed successfully!" 58 | else 59 | echo "❌ Keycloak setup failed!" 60 | exit 1 61 | fi 62 | -------------------------------------------------------------------------------- /spire/spire-down.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Shutting down SPIRE stack..." 5 | 6 | # 1. Stop and remove SPIRE containers 7 | echo "Stopping and removing SPIRE containers..." 8 | 9 | # Handle spire-agent (may be started with docker compose run) 10 | if docker ps -a | grep -q spire-agent; then 11 | echo "Stopping spire-agent..." 12 | docker stop spire-agent 2>/dev/null || true 13 | docker rm spire-agent 2>/dev/null || true 14 | fi 15 | 16 | # Handle compose-managed containers 17 | if docker compose ps | grep -q spire-agent; then 18 | echo "Stopping compose-managed spire-agent..." 19 | docker compose rm -sf spire-agent 20 | fi 21 | 22 | if docker compose ps | grep -q spire-oidc-discovery; then 23 | echo "Stopping spire-oidc-discovery..." 24 | docker compose rm -sf spire-oidc-discovery 25 | fi 26 | 27 | if docker compose ps | grep -q spire-server; then 28 | echo "Stopping spire-server..." 29 | docker compose rm -sf spire-server 30 | fi 31 | 32 | # 2. Remove SPIRE volumes 33 | echo "Removing SPIRE volumes..." 34 | if docker volume ls | grep -q spire_spire-server-socket; then 35 | echo "Removing spire-server-socket volume..." 36 | docker volume rm spire_spire-server-socket 37 | fi 38 | 39 | # 3. Note about network (owned by Keycloak) 40 | echo "" 41 | echo "Note: keycloak_keycloak-shared-network is owned by Keycloak and will not be removed." 42 | echo "To remove the network, stop Keycloak first: cd ../keycloak && docker compose down" 43 | 44 | echo "" 45 | echo "SPIRE stack shutdown complete!" 46 | echo "" 47 | echo "Remaining resources:" 48 | echo " - Certificates: $(ls -1 *.pem *.crt *.key 2>/dev/null | wc -l | tr -d ' ') files" 49 | echo " - keycloak_keycloak-shared-network: $(docker network ls | grep -c keycloak_keycloak-shared-network || echo "0") instances" 50 | echo "" 51 | echo "To completely clean up, you can also:" 52 | echo " - Remove certificates: rm *.pem *.crt *.key" 53 | echo " - Remove network (after stopping Keycloak): cd ../keycloak && docker compose down" -------------------------------------------------------------------------------- /spire/server_container.conf: -------------------------------------------------------------------------------- 1 | server { 2 | bind_address = "0.0.0.0" 3 | bind_port = "8081" 4 | socket_path = "/tmp/spire-server/private/api.sock" 5 | trust_domain = "example.org" 6 | data_dir = "/opt/spire/data/server" 7 | log_level = "DEBUG" 8 | ca_ttl = "24h" 9 | 10 | # Add JWT issuer for OIDC (using HTTP for local development) 11 | jwt_issuer = "http://spire-server:8443" 12 | default_jwt_svid_ttl = "1m" 13 | 14 | # Configure RSA key type (required for OIDC) 15 | ca_key_type = "rsa-2048" 16 | 17 | # Add federation bundle endpoint 18 | federation { 19 | bundle_endpoint { 20 | address = "0.0.0.0" 21 | port = 8443 22 | } 23 | } 24 | } 25 | 26 | plugins { 27 | DataStore "sql" { 28 | plugin_data { 29 | database_type = "sqlite3" 30 | connection_string = "/opt/spire/data/server/datastore.sqlite3" 31 | } 32 | } 33 | 34 | NodeAttestor "join_token" { 35 | plugin_data { 36 | } 37 | } 38 | 39 | KeyManager "memory" { 40 | plugin_data = {} 41 | } 42 | 43 | 44 | UpstreamAuthority "disk" { 45 | plugin_data { 46 | key_file_path = "/etc/spire/server/dummy_upstream_ca.key" 47 | cert_file_path = "/etc/spire/server/dummy_upstream_ca.crt" 48 | } 49 | } 50 | # Software Statements CredentialComposer Plugin 51 | CredentialComposer "software_statements" { 52 | plugin_cmd = "/opt/spire/plugins/spire-software-statements" 53 | plugin_checksum = "0b19c7f1ad1b80d0d7494f9e123cc89b41225f7d39784342b3be3cffb8e07985" 54 | plugin_data = { 55 | jwks_url = "http://spire-oidc-discovery:8443/keys" 56 | client_auth = "client-spiffe-jwt" 57 | allow_insecure_urls = true # Enable HTTP for testing 58 | # Optional: Additional claims 59 | additional_claims = { 60 | "scope" = "mcp:read mcp:tools mcp:prompts" 61 | "organization" = "Solo.io Agent IAM" 62 | "environment" = "production" 63 | } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /spire/generate_dummy_certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Paths 5 | KEY="dummy_upstream_ca.key" 6 | CRT="dummy_upstream_ca.crt" 7 | ROOTCRT="dummy_root_ca.crt" 8 | 9 | # New OIDC HTTPS certificate paths 10 | OIDC_KEY="oidc-https-key.pem" 11 | OIDC_CSR="oidc-https.csr" 12 | OIDC_CRT="oidc-https-cert.pem" 13 | 14 | # Check if files already exist 15 | if [[ -f "$KEY" || -f "$CRT" || -f "$ROOTCRT" || -f "$OIDC_KEY" || -f "$OIDC_CRT" ]]; then 16 | echo "One or more cert/key files already exist:" 17 | [[ -f "$KEY" ]] && echo " $KEY" 18 | [[ -f "$CRT" ]] && echo " $CRT" 19 | [[ -f "$ROOTCRT" ]] && echo " $ROOTCRT" 20 | [[ -f "$OIDC_KEY" ]] && echo " $OIDC_KEY" 21 | [[ -f "$OIDC_CRT" ]] && echo " $OIDC_CRT" 22 | read -p "Overwrite existing files? [y/N]: " yn 23 | case $yn in 24 | [Yy]*) echo "Overwriting..." ;; 25 | *) echo "Aborting."; exit 1 ;; 26 | esac 27 | fi 28 | 29 | echo "Generating SPIRE CA certificates..." 30 | # Generate dummy_upstream_ca.key and dummy_upstream_ca.crt 31 | openssl req -x509 -newkey rsa:2048 -days 365 -nodes \ 32 | -keyout "$KEY" \ 33 | -out "$CRT" \ 34 | -subj "/CN=Dummy Upstream CA" 35 | 36 | # Copy dummy_upstream_ca.crt as dummy_root_ca.crt for the agent 37 | cp "$CRT" "$ROOTCRT" 38 | 39 | echo "Generating OIDC HTTPS certificates..." 40 | # Generate OIDC HTTPS certificate request 41 | openssl req -newkey rsa:2048 -keyout "$OIDC_KEY" -out "$OIDC_CSR" -nodes \ 42 | -subj "/CN=spire-server" \ 43 | -addext "subjectAltName=DNS:spire-server,DNS:spire-oidc-discovery,DNS:localhost,IP:127.0.0.1" 44 | 45 | # Sign OIDC HTTPS certificate with SPIRE CA 46 | openssl x509 -req -in "$OIDC_CSR" -CA "$CRT" -CAkey "$KEY" \ 47 | -CAcreateserial -out "$OIDC_CRT" -days 365 48 | 49 | # Clean up CSR file 50 | rm "$OIDC_CSR" 51 | 52 | echo "All certificates generated:" 53 | echo " $KEY (SPIRE CA private key)" 54 | echo " $CRT (SPIRE CA certificate)" 55 | echo " $ROOTCRT (SPIRE root CA for agent)" 56 | echo " $OIDC_KEY (OIDC HTTPS private key)" 57 | echo " $OIDC_CRT (OIDC HTTPS certificate)" 58 | echo "" 59 | echo "Certificate hierarchy:" 60 | echo " SPIRE CA ($CRT) signs:" 61 | echo " ├── JWT-SVIDs (existing SPIRE functionality)" 62 | echo " └── OIDC HTTPS certificate ($OIDC_CRT)" -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Keycloak Agent Identity Makefile 2 | # This Makefile provides targets for building and pushing Docker images 3 | 4 | # Variables 5 | KEYCLOAK_IMAGE_NAME ?= keycloak-agent-identity 6 | SETUP_IMAGE_NAME ?= keycloak-agent-identity-setup 7 | IMAGE_TAG ?= latest 8 | KEYCLOAK_FULL_IMAGE_NAME = $(KEYCLOAK_IMAGE_NAME):$(IMAGE_TAG) 9 | SETUP_FULL_IMAGE_NAME = $(SETUP_IMAGE_NAME):$(IMAGE_TAG) 10 | PLATFORMS ?= linux/amd64,linux/arm64 11 | 12 | # Default target 13 | .PHONY: help 14 | help: ## Show this help message 15 | @echo "Available targets:" 16 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 17 | @echo "" 18 | @echo "Images built:" 19 | @echo " $(KEYCLOAK_FULL_IMAGE_NAME) - Keycloak with SPIFFE plugins" 20 | @echo " $(SETUP_FULL_IMAGE_NAME) - Python setup container" 21 | 22 | .PHONY: build 23 | build: ## Build multi-architecture Docker images (ARM64 + AMD64) - DEFAULT 24 | @echo "Building multi-architecture Keycloak image: $(KEYCLOAK_FULL_IMAGE_NAME)" 25 | @echo "Targeting platforms: $(PLATFORMS)" 26 | @if ! docker buildx version >/dev/null 2>&1; then \ 27 | echo "Error: Docker buildx is not available. Please enable buildx or upgrade Docker."; \ 28 | exit 1; \ 29 | fi 30 | docker buildx build --platform $(PLATFORMS) -t $(KEYCLOAK_FULL_IMAGE_NAME) --load . 31 | @echo "Building multi-architecture setup image: $(SETUP_FULL_IMAGE_NAME)" 32 | docker buildx build --platform $(PLATFORMS) -f Dockerfile.setup -t $(SETUP_FULL_IMAGE_NAME) --load . 33 | @echo "Multi-architecture build complete!" 34 | 35 | .PHONY: push 36 | push: build ## Build and push both Docker images to Docker Hub 37 | @echo "Tagging images for Docker Hub..." 38 | docker tag $(KEYCLOAK_FULL_IMAGE_NAME) ceposta/$(KEYCLOAK_IMAGE_NAME):$(IMAGE_TAG) 39 | docker tag $(SETUP_FULL_IMAGE_NAME) ceposta/$(SETUP_IMAGE_NAME):$(IMAGE_TAG) 40 | @echo "Pushing Keycloak image to Docker Hub..." 41 | docker push ceposta/$(KEYCLOAK_IMAGE_NAME):$(IMAGE_TAG) 42 | @echo "Pushing setup image to Docker Hub..." 43 | docker push ceposta/$(SETUP_IMAGE_NAME):$(IMAGE_TAG) 44 | @echo "Push complete! Images available at:" 45 | @echo " ceposta/$(KEYCLOAK_IMAGE_NAME):$(IMAGE_TAG)" 46 | @echo " ceposta/$(SETUP_IMAGE_NAME):$(IMAGE_TAG)" 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage Dockerfile for Keycloak Agent Identity 2 | # This creates a Keycloak image with SPIFFE plugins and setup scripts 3 | # The actual setup is handled by a separate Python container 4 | 5 | # Stage 1: Build custom Keycloak image with SPIFFE plugins 6 | FROM quay.io/keycloak/keycloak:26.2.5 AS keycloak-base 7 | 8 | # Copy SPIFFE plugins into Keycloak providers directory 9 | COPY keycloak/spiffe-svid-client-authenticator-1.0.0.jar /opt/keycloak/providers/ 10 | COPY keycloak/spiffe-dcr-spi-1.0.0.jar /opt/keycloak/providers/ 11 | 12 | # Stage 2: Python environment with setup scripts 13 | FROM python:3.11-slim AS python-setup 14 | 15 | # Install system dependencies 16 | RUN apt-get update && apt-get install -y \ 17 | curl \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | # Install Python dependencies 21 | RUN pip install --no-cache-dir requests 22 | 23 | # Create app directory 24 | WORKDIR /app 25 | 26 | # Copy Python setup scripts 27 | COPY keycloak/setup_keycloak.py /app/ 28 | COPY keycloak/boot_keycloak.py /app/ 29 | COPY run_keycloak.py /app/ 30 | 31 | # Stage 3: Final Keycloak image with SPIFFE plugins 32 | FROM quay.io/keycloak/keycloak:26.2.5 AS final 33 | 34 | # Copy SPIFFE plugins from keycloak-base stage 35 | COPY --from=keycloak-base /opt/keycloak/providers/spiffe-svid-client-authenticator-1.0.0.jar /opt/keycloak/providers/ 36 | COPY --from=keycloak-base /opt/keycloak/providers/spiffe-dcr-spi-1.0.0.jar /opt/keycloak/providers/ 37 | 38 | # Create app directory 39 | WORKDIR /app 40 | 41 | # Create entrypoint script 42 | COPY <0) { printf "%s", $0; for(i=1;i<=4-l;i++) printf "="; print "" } else print $0 }' | base64 -d 2>/dev/null | jq .) 43 | payload=$(echo "$jwt" | cut -d. -f2 | tr '_-' '/+' | awk '{ l=length($0)%4; if(l>0) { printf "%s", $0; for(i=1;i<=4-l;i++) printf "="; print "" } else print $0 }' | base64 -d 2>/dev/null | jq .) 44 | echo "Header:" 45 | echo "$header" 46 | echo "Claims:" 47 | echo "$payload" 48 | } 49 | 50 | echo "JWT: $JWT" 51 | if [ -n "$JWT" ]; then 52 | echo 53 | echo "Decoded JWT SVID:" 54 | decode_jwt "$JWT" 55 | 56 | # Extract and display expiration time 57 | EXP=$(echo "$JWT" | cut -d. -f2 | tr '_-' '/+' | awk '{ l=length($0)%4; if(l>0) { printf "%s", $0; for(i=1;i<=4-l;i++) printf "="; print "" } else print $0 }' | base64 -d 2>/dev/null | jq -r '.exp') 58 | if [ -n "$EXP" ] && [ "$EXP" != "null" ]; then 59 | EXP_DATE=$(date -r "$EXP" 2>/dev/null || date -d "@$EXP" 2>/dev/null) 60 | echo 61 | echo "Expires: $EXP_DATE (Unix timestamp: $EXP)" 62 | fi 63 | fi -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # Keycloak Agent Identity - Docker Usage 2 | 3 | This project provides Docker containers for Keycloak with SPIFFE authentication plugins and automatic configuration setup. 4 | 5 | ## Architecture 6 | 7 | - **Main Container**: Keycloak with SPIFFE plugins (`Dockerfile`) 8 | - **Setup Container**: Python scripts for configuration (`Dockerfile.setup`) 9 | 10 | ## Quick Start 11 | 12 | ### Build Images 13 | 14 | ```bash 15 | # Build main Keycloak image (with SPIFFE plugins baked in) 16 | docker build -t keycloak-agent-identity . 17 | 18 | # Build setup image (for configuration) 19 | docker build -f Dockerfile.setup -t keycloak-agent-identity-setup . 20 | ``` 21 | 22 | ### Run Setup Container 23 | ```bash 24 | # Run the setup container to configure an existing Keycloak instance 25 | docker compose up 26 | ``` 27 | 28 | The setup container will: 29 | 1. Wait for Keycloak to be ready at `http://host.docker.internal:8080` 30 | 2. Configure Keycloak using your `config.json` file 31 | 3. Exit when complete 32 | 33 | ## Configuration 34 | 35 | ### Environment Variables (Setup Container) 36 | - `KEYCLOAK_URL`: Keycloak URL (default: `http://localhost:8080`) 37 | - `CONFIG_FILE`: Path to config file (default: `/app/config.json`) 38 | - `ADMIN_USERNAME`: Admin username (default: `admin`) 39 | - `ADMIN_PASSWORD`: Admin password (default: `admin`) 40 | - `MAX_RETRIES`: Max retries waiting for Keycloak (default: `30`) 41 | 42 | ### Config File 43 | Mount your `config.json` file that defines: 44 | - Realm settings with SPIFFE attributes 45 | - Clients and their configurations 46 | - Client scopes and role mappings 47 | - Users and their roles 48 | - Authentication flows 49 | 50 | ## Manual Usage 51 | 52 | ### Run Setup Container Directly 53 | ```bash 54 | docker run --rm \ 55 | -v $(pwd)/config.json:/app/config.json \ 56 | -e KEYCLOAK_URL=http://host.docker.internal:8080 \ 57 | keycloak-agent-identity-setup 58 | ``` 59 | 60 | ### Run Keycloak Container 61 | ```bash 62 | docker run -p 8080:8080 keycloak-agent-identity 63 | ``` 64 | 65 | ## Kubernetes Deployment 66 | 67 | For Kubernetes, use the setup container as an init container: 68 | - **Init Container**: `keycloak-agent-identity-setup` configures Keycloak before startup 69 | - **Main Container**: `keycloak-agent-identity` runs the server 70 | - See `k8s-deployment.yaml` for complete example 71 | 72 | ## Features 73 | 74 | - ✅ Keycloak 26.2.5 with SPIFFE plugins baked in 75 | - ✅ Automatic configuration from JSON file 76 | - ✅ Init container pattern for Kubernetes 77 | - ✅ Configurable via environment variables 78 | - ✅ Production-ready setup -------------------------------------------------------------------------------- /run_spire.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Wrapper script to run spire-up.sh or spire-down.sh from the spire directory. 4 | This allows uv run spire [--down] to work from the project root. 5 | """ 6 | 7 | import os 8 | import sys 9 | import subprocess 10 | import argparse 11 | from pathlib import Path 12 | 13 | def main(): 14 | # Parse command line arguments 15 | parser = argparse.ArgumentParser(description="Manage SPIRE stack") 16 | parser.add_argument("--down", action="store_true", help="Shutdown SPIRE stack") 17 | args = parser.parse_args() 18 | 19 | # Get the project root directory (where this script is located) 20 | project_root = Path(__file__).parent 21 | spire_dir = project_root / "spire" 22 | 23 | # Check if spire directory exists 24 | if not spire_dir.exists(): 25 | print(f"Error: spire directory not found at {spire_dir}") 26 | sys.exit(1) 27 | 28 | # Determine which script to run 29 | if args.down: 30 | script_name = "spire-down.sh" 31 | action_verb = "Shutting down" 32 | success_msg = "✅ SPIRE stack shutdown completed!" 33 | else: 34 | script_name = "spire-up.sh" 35 | action_verb = "Starting" 36 | success_msg = "✅ SPIRE stack started successfully!" 37 | 38 | spire_script = spire_dir / script_name 39 | if not spire_script.exists(): 40 | print(f"Error: {script_name} not found at {spire_script}") 41 | sys.exit(1) 42 | 43 | # Change to the spire directory 44 | original_cwd = os.getcwd() 45 | os.chdir(spire_dir) 46 | 47 | try: 48 | # Make sure the script is executable 49 | os.chmod(spire_script, 0o755) 50 | 51 | # Run the appropriate script 52 | print(f"🚀 {action_verb} SPIRE stack...") 53 | result = subprocess.run( 54 | [f"./{script_name}"], 55 | cwd=spire_dir, 56 | check=True 57 | ) 58 | 59 | if result.returncode == 0: 60 | print(success_msg) 61 | else: 62 | print(f"❌ SPIRE script failed with exit code {result.returncode}") 63 | sys.exit(result.returncode) 64 | 65 | except subprocess.CalledProcessError as e: 66 | print(f"❌ Error running SPIRE script: {e}") 67 | sys.exit(e.returncode) 68 | except Exception as e: 69 | print(f"❌ Unexpected error: {e}") 70 | sys.exit(1) 71 | finally: 72 | # Restore original working directory 73 | os.chdir(original_cwd) 74 | 75 | if __name__ == "__main__": 76 | main() 77 | -------------------------------------------------------------------------------- /spire/spire-up.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # 0. Check for required certificates (SPIRE certificates only) 5 | echo "Checking for required certificates..." 6 | if [[ ! -f "dummy_upstream_ca.key" || ! -f "dummy_upstream_ca.crt" || ! -f "dummy_root_ca.crt" ]]; then 7 | echo "Missing required certificates. Generating them..." 8 | ./generate_dummy_certs.sh 9 | else 10 | echo "All certificates found." 11 | fi 12 | 13 | # 1. Check if keycloak_keycloak-shared-network exists (should be created by Keycloak) 14 | echo "Checking for keycloak_keycloak-shared-network..." 15 | if ! docker network ls | grep -q keycloak_keycloak-shared-network; then 16 | echo "ERROR: keycloak_keycloak-shared-network not found!" 17 | echo "Please start Keycloak first to create the shared network." 18 | echo "Run: cd ../keycloak && docker compose up -d" 19 | exit 1 20 | else 21 | echo "keycloak_keycloak-shared-network found." 22 | fi 23 | 24 | # 2. Start the server and OIDC discovery provider (everything except agent) 25 | echo "Starting SPIRE server and OIDC discovery provider..." 26 | docker compose up -d spire-server spire-oidc-discovery 27 | 28 | # 3. Wait for the server to be healthy 29 | until docker compose exec spire-server /opt/spire/bin/spire-server healthcheck; do 30 | echo "Waiting for SPIRE server to be healthy..." 31 | sleep 2 32 | done 33 | 34 | echo "SPIRE server is healthy." 35 | 36 | # 4. Wait for the OIDC discovery provider to be ready 37 | echo "Waiting for OIDC discovery provider to be ready..." 38 | until curl -s http://localhost:18443/keys > /dev/null 2>&1; do 39 | echo "Waiting for OIDC discovery provider..." 40 | sleep 2 41 | done 42 | 43 | echo "OIDC discovery provider is ready." 44 | 45 | # 5. Generate a join token 46 | TOKEN=$(docker compose exec spire-server /opt/spire/bin/spire-server token generate -spiffeID spiffe://example.org/agent | grep 'Token:' | awk '{print $2}') 47 | echo "Join token: $TOKEN" 48 | 49 | # 6. Start the agent with the join token 50 | # Remove any existing agent container 51 | if docker compose ps | grep -q spire-agent; then 52 | echo "Stopping any existing spire-agent container..." 53 | docker compose rm -sf spire-agent 54 | fi 55 | 56 | echo "Starting spire-agent with join token..." 57 | docker compose run -d --name spire-agent spire-agent -config "/etc/spire/agent/agent.conf" -joinToken "$TOKEN" 58 | 59 | echo "SPIRE stack is up!" 60 | echo "" 61 | echo "Services running:" 62 | echo " - SPIRE Server: localhost:18081" 63 | echo " - OIDC Discovery Provider: localhost:18443" 64 | echo " - SPIRE Agent: (internal)" 65 | echo "" 66 | echo "Test OIDC endpoints:" 67 | echo " - Discovery document: curl http://localhost:18443/.well-known/openid-configuration" 68 | echo " - JWKS endpoint: curl http://localhost:18443/keys" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Keycloak and SPIRE for Agent Identity 2 | 3 | A development environment for integrating Keycloak with SPIRE for workload identity and MCP (Model Context Protocol) authentication. 4 | 5 | Specifically this project allows you to : 6 | 7 | * Quickly bootstrap a test Keycloak for local dev 8 | * Configure clients / authentication mechansims / flows / scopes / mappings 9 | * Pre-loads SPIs for DCR based on SPIFFE and Client Authentication based on SPIFFE 10 | * Based on these blogs: 11 | * [Implementing MCP Dynamic Client Registration With SPIFFE and Keycloak](https://blog.christianposta.com/implementing-mcp-dynamic-client-registration-with-spiffe/) 12 | * [Authenticating MCP OAuth Clients With SPIFFE and SPIRE](https://blog.christianposta.com/authenticating-mcp-oauth-clients-with-spiffe/) 13 | 14 | Uses these three projects as dependencies to implement the SPI / SPIRE plugins: 15 | 16 | * https://github.com/christian-posta/spiffe-svid-client-authenticator 17 | * https://github.com/christian-posta/spiffe-dcr-keycloak 18 | * https://github.com/christian-posta/spire-software-statements 19 | 20 | 21 | ## Quick Start 22 | 23 | ```bash 24 | # Start Keycloak (uses config.json by default) 25 | uv run keycloak 26 | 27 | # Start SPIRE components 28 | uv run spire 29 | 30 | # Stop services 31 | uv run keycloak --down 32 | uv run spire --down 33 | ``` 34 | 35 | ## Configuration 36 | 37 | - **Default config**: Uses `config.json` from project root 38 | - **Custom config**: `uv run keycloak --config path/to/config.json` 39 | - **Verbose output**: Add `--verbose` to any command 40 | 41 | ## Services 42 | 43 | - **Keycloak**: Identity provider on http://localhost:8080 44 | - **SPIRE**: Workload identity with SVID-based authentication 45 | - **MCP Integration**: Token exchange and SPIFFE-based client authentication 46 | 47 | ## Development 48 | 49 | ```bash 50 | # Install dependencies 51 | uv sync 52 | 53 | # View logs 54 | docker compose -f keycloak/docker-compose.yml logs 55 | docker compose -f spire/docker-compose.yml logs 56 | ``` 57 | 58 | 59 | After setting things up, you can test a DCR example with the following: 60 | 61 | ```bash 62 | ./spire/test-spiffe-drc.sh 63 | ``` 64 | 65 | 66 | To run a test of the client authentication: 67 | 68 | ```bash 69 | ./spire/test-spiffe-authentication.sh 70 | ``` 71 | 72 | ## Run on Kubernetes 73 | 74 | First install the right config.json: 75 | 76 | ```bash 77 | kubectl create configmap keycloak-config --from-file=config.json=config.json 78 | ``` 79 | 80 | Example: 81 | 82 | ```bash 83 | kubectl create configmap keycloak-config --from-file=config.json=/Users/christian.posta/python/agent-auth-istio-keycloak/keycloak/config.json 84 | ``` 85 | 86 | Then install keycloak and setup helper job: 87 | 88 | ```bash 89 | kubectl apply -f k8s-deployment.yaml 90 | ``` -------------------------------------------------------------------------------- /k8s-deployment.yaml: -------------------------------------------------------------------------------- 1 | # apiVersion: v1 2 | # kind: ConfigMap 3 | # metadata: 4 | # name: keycloak-config 5 | # namespace: default 6 | # data: 7 | # config.json: | 8 | # Install this CM first!! 9 | # Your config.json content goes here 10 | --- 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | metadata: 14 | name: keycloak-agent-identity 15 | namespace: default 16 | labels: 17 | app: keycloak-agent-identity 18 | spec: 19 | replicas: 1 20 | selector: 21 | matchLabels: 22 | app: keycloak-agent-identity 23 | template: 24 | metadata: 25 | labels: 26 | app: keycloak-agent-identity 27 | spec: 28 | containers: 29 | - name: keycloak 30 | image: ceposta/keycloak-agent-identity:latest 31 | ports: 32 | - containerPort: 8080 33 | name: http 34 | env: 35 | - name: KC_HEALTH_ENABLED 36 | value: "true" 37 | - name: KC_BOOTSTRAP_ADMIN_USERNAME 38 | value: "admin" 39 | - name: KC_BOOTSTRAP_ADMIN_PASSWORD 40 | valueFrom: 41 | secretKeyRef: 42 | name: keycloak-admin 43 | key: password 44 | - name: KC_HTTP_ENABLED 45 | value: "true" 46 | - name: KC_HOSTNAME_STRICT 47 | value: "false" 48 | livenessProbe: 49 | httpGet: 50 | path: /realms/master 51 | port: 8080 52 | initialDelaySeconds: 60 53 | periodSeconds: 30 54 | timeoutSeconds: 10 55 | readinessProbe: 56 | httpGet: 57 | path: /realms/master 58 | port: 8080 59 | initialDelaySeconds: 30 60 | periodSeconds: 10 61 | timeoutSeconds: 5 62 | resources: 63 | requests: 64 | cpu: 500m 65 | memory: 1Gi 66 | limits: 67 | cpu: 2000m 68 | memory: 2Gi 69 | volumes: 70 | - name: config 71 | configMap: 72 | name: keycloak-config 73 | --- 74 | apiVersion: batch/v1 75 | kind: Job 76 | metadata: 77 | name: keycloak-setup-job 78 | namespace: default 79 | labels: 80 | app: keycloak-agent-identity 81 | component: setup 82 | spec: 83 | template: 84 | metadata: 85 | labels: 86 | app: keycloak-agent-identity 87 | component: setup 88 | spec: 89 | restartPolicy: Never 90 | containers: 91 | - name: keycloak-setup 92 | image: ceposta/keycloak-agent-identity-setup:latest 93 | env: 94 | - name: KEYCLOAK_URL 95 | value: "http://keycloak-agent-identity:8080" # Use service name 96 | - name: CONFIG_FILE 97 | value: "/app/config.json" 98 | - name: ADMIN_USERNAME 99 | value: "admin" 100 | - name: ADMIN_PASSWORD 101 | valueFrom: 102 | secretKeyRef: 103 | name: keycloak-admin 104 | key: password 105 | - name: MAX_RETRIES 106 | value: "60" # More retries for K8s startup 107 | volumeMounts: 108 | - name: config 109 | mountPath: /app/config.json 110 | subPath: config.json 111 | readOnly: true 112 | resources: 113 | requests: 114 | cpu: 100m 115 | memory: 128Mi 116 | limits: 117 | cpu: 500m 118 | memory: 512Mi 119 | volumes: 120 | - name: config 121 | configMap: 122 | name: keycloak-config 123 | backoffLimit: 3 # Retry up to 3 times if setup fails 124 | --- 125 | apiVersion: v1 126 | kind: Secret 127 | metadata: 128 | name: keycloak-admin 129 | namespace: default 130 | type: Opaque 131 | data: 132 | password: YWRtaW4= # base64 encoded "admin" - change this! 133 | --- 134 | apiVersion: v1 135 | kind: Service 136 | metadata: 137 | name: keycloak-agent-identity 138 | namespace: default 139 | labels: 140 | app: keycloak-agent-identity 141 | spec: 142 | type: ClusterIP 143 | ports: 144 | - port: 8080 145 | targetPort: 8080 146 | protocol: TCP 147 | name: http 148 | selector: 149 | app: keycloak-agent-identity 150 | 151 | -------------------------------------------------------------------------------- /spire/README.md: -------------------------------------------------------------------------------- 1 | # SPIRE Docker Compose Demo 2 | 3 | This folder contains a minimal Docker Compose setup for running SPIRE Server and Agent, suitable for local development and testing with an MCP client. 4 | 5 | ## Overview 6 | - **Ephemeral setup**: All data is stored inside the containers and will be lost if the containers are removed or restarted. 7 | - **SPIRE Server and Agent run in separate containers**. 8 | - **SPIRE Agent exposes the Workload API over TCP** (unauthenticated, for demo/dev only) 9 | - **Default trust domain**: `example.org` 10 | 11 | ## Ports and Endpoints 12 | 13 | | Service | Host Port | Container Port | Purpose/How to Use | 14 | |-----------------|-----------|---------------|-------------------------------------------| 15 | | SPIRE Server | 18081 | 8081 | Management API (token generation, entries) | 16 | | SPIRE Agent API | | | Workload API (UDS, no exposed TCP) | 17 | 18 | - **SPIRE Server API**: `localhost:18081` 19 | - **SPIRE Agent Workload API (TCP)**: `localhost:18082` 20 | 21 | ## Usage 22 | 23 | 1. **Start the stack:** 24 | ```bash 25 | ./start-spire.sh 26 | ``` 27 | 2. **Fetch a JWT SVID from your MCP client:** 28 | ```bash 29 | ./get-svid.sh 30 | ``` 31 | 32 | Inspect the token and verify it looks right. 33 | 34 | 3. **Verify OIDC Discovery** 35 | ```bash 36 | curl http://localhost:18443/.well-known/openid-configuration 37 | ``` 38 | 39 | 4. **Verify JWKS** 40 | ```bash 41 | curl http://localhost:18443/keys 42 | ``` 43 | 44 | Note, the SPIRE issuer for JWTs is: 45 | ```text 46 | http://spire-server:8443 47 | ``` 48 | 49 | JWKS URL for keycloak: 50 | ``` 51 | http://spire-oidc-discovery:8443/keys 52 | ``` 53 | 54 | ## How to Change the Trust Domain 55 | 56 | 1. **Edit the config files:** 57 | - `server_container.conf`: Change the `trust_domain` value. 58 | - `agent_container.conf`: Change the `trust_domain` and update `trust_bundle_path` if needed. 59 | 2. **Update registration commands:** 60 | - Use the new trust domain in all `spiffe:///...` IDs. 61 | 62 | 63 | ## Notes 64 | - This setup is for demo/dev only. The Workload API is exposed over TCP without authentication. 65 | - All data is ephemeral. For persistent storage, add Docker volumes. 66 | - For more advanced scenarios, see the [SPIRE documentation](https://spiffe.io/docs/latest/spire/). 67 | 68 | ## Troubleshooting 69 | 70 | ### Error: unable to load upstream CA key: is a directory 71 | - Ensure `dummy_upstream_ca.key` and `dummy_upstream_ca.crt` in this folder are files, not directories. 72 | - If you accidentally created a directory, delete it and copy the correct file from the supporting SPIRE config. 73 | - The volume mounts in `docker-compose.yml` should look like: 74 | ```yaml 75 | - ./dummy_upstream_ca.key:/etc/spire/server/dummy_upstream_ca.key:ro 76 | - ./dummy_upstream_ca.crt:/etc/spire/server/dummy_upstream_ca.crt:ro 77 | ``` 78 | 79 | ### Error: admin socket cannot be in the same directory or a subdirectory as that containing the Workload API socket 80 | - In `agent_container.conf`, make sure `admin_socket_path` is **not** in the same directory as `socket_path`. 81 | - Example fix: 82 | ```hcl 83 | admin_socket_path = "/run/spire/agent/admin.sock" 84 | ``` 85 | - Restart the containers after making this change. 86 | 87 | ### How to generate dummy CA certs and keys if missing 88 | If you do not have the required files (`dummy_upstream_ca.crt`, `dummy_upstream_ca.key`, `dummy_root_ca.crt`), you can generate them with the following commands: 89 | 90 | ```bash 91 | # Generate dummy_upstream_ca.key and dummy_upstream_ca.crt 92 | openssl req -x509 -newkey rsa:2048 -days 365 -nodes \ 93 | -keyout dummy_upstream_ca.key \ 94 | -out dummy_upstream_ca.crt \ 95 | -subj "/CN=Dummy Upstream CA" 96 | 97 | # Copy dummy_upstream_ca.crt as dummy_root_ca.crt for the agent 98 | cp dummy_upstream_ca.crt dummy_root_ca.crt 99 | ``` 100 | 101 | - Place these files in the `spire/` folder. 102 | - Make sure they are files, not directories. 103 | - Restart your containers after generating these files. 104 | 105 | 106 | ## Notes 107 | 108 | We cannot use identity brokering in keycloak because SPIRE does not implement authorization code which is a pre-req in keycloak to do brokering 109 | 110 | We can try using client-jwt, but Keycloak expects the isuser and subject to be the same (since it's client issued JWT). With SPIRE this will not be the case. -------------------------------------------------------------------------------- /run_keycloak.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Wrapper script to run boot_keycloak.py from the keycloak directory. 4 | This allows uv run keycloak to work from the project root. 5 | Defaults to using config.json at the project root. 6 | """ 7 | 8 | import os 9 | import sys 10 | import argparse 11 | from pathlib import Path 12 | 13 | def main(): 14 | # Get the project root directory (where this script is located) 15 | project_root = Path(__file__).parent 16 | keycloak_dir = project_root / "keycloak" 17 | 18 | # Parse arguments to handle config file path 19 | parser = argparse.ArgumentParser(description="Boot and configure Keycloak", add_help=False) 20 | parser.add_argument("--config", "--configure", default=None, help="Path to config file") 21 | parser.add_argument("--url", default=None, help="Keycloak URL") 22 | parser.add_argument("--summary", action="store_true", help="Show detailed summary") 23 | parser.add_argument("--verbose", action="store_true", help="Verbose output") 24 | parser.add_argument("--down", action="store_true", help="Stop Keycloak containers") 25 | parser.add_argument("--help", "-h", action="store_true", help="Show help") 26 | 27 | # Parse known args to avoid errors with unrecognized arguments 28 | args, unknown_args = parser.parse_known_args() 29 | 30 | # If help is requested, pass it through to boot_keycloak.py 31 | if args.help: 32 | os.chdir(keycloak_dir) 33 | sys.path.insert(0, str(keycloak_dir)) 34 | from boot_keycloak import main as keycloak_main 35 | sys.argv = ["boot_keycloak.py", "--help"] 36 | keycloak_main() 37 | return 38 | 39 | # If --down is requested, stop Keycloak containers 40 | if args.down: 41 | import subprocess 42 | os.chdir(keycloak_dir) 43 | 44 | print("🔽 Stopping Keycloak containers...") 45 | try: 46 | result = subprocess.run( 47 | ["docker", "compose", "down"], 48 | check=True, 49 | capture_output=True, 50 | text=True 51 | ) 52 | print("✅ Keycloak containers stopped successfully") 53 | if args.verbose: 54 | print(f"Command output: {result.stdout}") 55 | except subprocess.CalledProcessError as e: 56 | print(f"❌ Failed to stop Keycloak containers: {e}") 57 | if e.stderr: 58 | print(f"Error details: {e.stderr}") 59 | sys.exit(1) 60 | except Exception as e: 61 | print(f"❌ Unexpected error stopping Keycloak: {e}") 62 | sys.exit(1) 63 | return 64 | 65 | # Default to config.json at project root if no config specified 66 | if args.config is None: 67 | default_config = project_root / "config.json" 68 | if default_config.exists(): 69 | # Use absolute path to config.json at project root 70 | config_path = str(default_config) 71 | else: 72 | # Fall back to keycloak/config.json (original behavior) 73 | config_path = "config.json" # This will be relative to keycloak dir 74 | else: 75 | # User specified a config path 76 | config_arg = Path(args.config) 77 | if config_arg.is_absolute(): 78 | config_path = str(config_arg) 79 | else: 80 | # Make it relative to project root, then convert to absolute 81 | config_path = str((project_root / config_arg).resolve()) 82 | 83 | # Change to the keycloak directory 84 | os.chdir(keycloak_dir) 85 | 86 | # Rebuild sys.argv for boot_keycloak.py 87 | new_argv = ["boot_keycloak.py"] 88 | 89 | # Add the config argument 90 | new_argv.extend(["--config", config_path]) 91 | 92 | # Add other arguments 93 | if args.url: 94 | new_argv.extend(["--url", args.url]) 95 | if args.summary: 96 | new_argv.append("--summary") 97 | if args.verbose: 98 | new_argv.append("--verbose") 99 | 100 | # Add any unknown arguments 101 | new_argv.extend(unknown_args) 102 | 103 | # Set sys.argv for boot_keycloak 104 | sys.argv = new_argv 105 | 106 | # Import and run the main function from boot_keycloak 107 | sys.path.insert(0, str(keycloak_dir)) 108 | 109 | try: 110 | from boot_keycloak import main as keycloak_main 111 | keycloak_main() 112 | except ImportError as e: 113 | print(f"Error importing boot_keycloak: {e}") 114 | sys.exit(1) 115 | except Exception as e: 116 | print(f"Error running keycloak: {e}") 117 | sys.exit(1) 118 | 119 | if __name__ == "__main__": 120 | main() 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | spire/dummy_* 2 | spire/oidc-https* 3 | **/keycloak_access_token.txt 4 | supporting 5 | client_credentials.json 6 | token_* 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/#use-with-ide 116 | .pdm.toml 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .env 130 | .venv 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be added to the global gitignore or merged into this project gitignore. For a PyCharm 164 | # project, it is recommended to include the following files: 165 | # .idea/ 166 | # *.iml 167 | # *.ipr 168 | # *.iws 169 | .idea/ 170 | *.iml 171 | *.ipr 172 | *.iws 173 | 174 | # VS Code 175 | .vscode/ 176 | *.code-workspace 177 | 178 | # Sublime Text 179 | *.sublime-project 180 | *.sublime-workspace 181 | 182 | # Vim 183 | *.swp 184 | *.swo 185 | *~ 186 | 187 | # Emacs 188 | *~ 189 | \#*\# 190 | /.emacs.desktop 191 | /.emacs.desktop.lock 192 | *.elc 193 | auto-save-list 194 | tramp 195 | .\#* 196 | 197 | # macOS 198 | .DS_Store 199 | .AppleDouble 200 | .LSOverride 201 | Icon 202 | ._* 203 | .DocumentRevisions-V100 204 | .fseventsd 205 | .Spotlight-V100 206 | .TemporaryItems 207 | .Trashes 208 | .VolumeIcon.icns 209 | .com.apple.timemachine.donotpresent 210 | .AppleDB 211 | .AppleDesktop 212 | Network Trash Folder 213 | Temporary Items 214 | .apdisk 215 | 216 | # Windows 217 | Thumbs.db 218 | Thumbs.db:encryptable 219 | ehthumbs.db 220 | ehthumbs_vista.db 221 | *.tmp 222 | *.temp 223 | Desktop.ini 224 | $RECYCLE.BIN/ 225 | *.cab 226 | *.msi 227 | *.msix 228 | *.msm 229 | *.msp 230 | *.lnk 231 | 232 | # Linux 233 | *~ 234 | .fuse_hidden* 235 | .directory 236 | .Trash-* 237 | .nfs* 238 | 239 | # uv specific 240 | .uv/ 241 | .venv/ 242 | 243 | # JWT tokens and secrets 244 | *.jwt 245 | *.token 246 | secrets.json 247 | .env.local 248 | .env.production 249 | .env.staging 250 | 251 | # Logs 252 | *.log 253 | logs/ 254 | 255 | # Temporary files 256 | *.tmp 257 | *.temp 258 | temp/ 259 | tmp/ 260 | 261 | # Database files 262 | *.db 263 | *.sqlite 264 | *.sqlite3 265 | 266 | # Docker 267 | .dockerignore 268 | 269 | # Local development 270 | .local/ 271 | local/ 272 | 273 | # Test artifacts 274 | test_*.py 275 | *_test.py 276 | .pytest_cache/ 277 | 278 | # Documentation builds 279 | docs/build/ 280 | site/ 281 | 282 | # Backup files 283 | *.bak 284 | *.backup 285 | *.old 286 | 287 | # IDE and editor files 288 | *.swp 289 | *.swo 290 | *~ 291 | .vscode/ 292 | .idea/ 293 | *.sublime-* 294 | 295 | # OS generated files 296 | .DS_Store 297 | .DS_Store? 298 | ._* 299 | .Spotlight-V100 300 | .Trashes 301 | ehthumbs.db 302 | Thumbs.db -------------------------------------------------------------------------------- /spire/test-spiffe-authentication.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Determine script directory and spire directory 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | SPIRE_DIR="$SCRIPT_DIR" 7 | ORIGINAL_DIR="$(pwd)" 8 | 9 | # If script is being run from project root, adjust the spire directory path 10 | if [[ "$(basename "$SCRIPT_DIR")" == "spire" ]]; then 11 | # Script is in spire directory, no change needed 12 | SPIRE_DIR="$SCRIPT_DIR" 13 | else 14 | # Script might be run from elsewhere, find spire directory 15 | if [[ -d "$ORIGINAL_DIR/spire" ]]; then 16 | SPIRE_DIR="$ORIGINAL_DIR/spire" 17 | elif [[ -d "$(dirname "$SCRIPT_DIR")/spire" ]]; then 18 | SPIRE_DIR="$(dirname "$SCRIPT_DIR")/spire" 19 | else 20 | echo "❌ Cannot find spire directory with docker-compose.yml" 21 | exit 1 22 | fi 23 | fi 24 | 25 | echo "🔧 Script directory: $SCRIPT_DIR" 26 | echo "🔧 SPIRE directory: $SPIRE_DIR" 27 | echo "🔧 Original directory: $ORIGINAL_DIR" 28 | echo 29 | 30 | # Quick validation that docker-compose.yml exists in SPIRE_DIR 31 | if [[ ! -f "$SPIRE_DIR/docker-compose.yml" ]]; then 32 | echo "❌ docker-compose.yml not found in $SPIRE_DIR" 33 | exit 1 34 | fi 35 | echo "✅ Found docker-compose.yml in $SPIRE_DIR" 36 | echo 37 | 38 | # Configuration 39 | WORKLOAD_SPIFFE_ID="spiffe://example.org/mcp-test-client" 40 | PARENT_SPIFFE_ID="spiffe://example.org/agent" 41 | AUDIENCE="http://localhost:8080/realms/mcp-realm" 42 | KEYCLOAK_URL="http://localhost:8080" 43 | KEYCLOAK_REALM="mcp-realm" 44 | CLIENT_ID="spiffe://example.org/mcp-test-client" 45 | 46 | prompt_continue() { 47 | read -r -p "Continue (Y/n)? " response 48 | response=${response:-Y} 49 | if [[ "$response" =~ ^[Nn]$ ]]; then 50 | echo "Aborting." 51 | exit 1 52 | fi 53 | } 54 | 55 | echo "=== SPIRE + Keycloak JWT Client Credentials Flow ===" 56 | echo "Workload SPIFFE ID: $WORKLOAD_SPIFFE_ID" 57 | echo "Audience: $AUDIENCE" 58 | echo "Keycloak URL: $KEYCLOAK_URL" 59 | echo "Keycloak Realm: $KEYCLOAK_REALM" 60 | echo "Client ID: $CLIENT_ID" 61 | echo 62 | 63 | # 1. Check if workload entry exists and register if needed 64 | echo "Step 1: Checking/Registering SPIRE workload entry..." 65 | if (cd "$SPIRE_DIR" && docker compose exec spire-server /opt/spire/bin/spire-server entry show -spiffeID "$WORKLOAD_SPIFFE_ID") | grep -q "Entry ID"; then 66 | echo "✅ Workload entry already exists." 67 | else 68 | echo "📝 Registering workload entry..." 69 | (cd "$SPIRE_DIR" && docker compose exec spire-server /opt/spire/bin/spire-server entry create \ 70 | -parentID "$PARENT_SPIFFE_ID" \ 71 | -spiffeID "$WORKLOAD_SPIFFE_ID" \ 72 | -jwtSVIDTTL 60 \ 73 | -selector unix:uid:0) 74 | echo "✅ Workload entry created." 75 | fi 76 | prompt_continue 77 | 78 | # 2. Fetch JWT SVID using spire-agent CLI with retry logic 79 | echo 80 | echo "Step 2: Fetching JWT SVID from SPIRE..." 81 | 82 | # Retry configuration 83 | MAX_ATTEMPTS=10 84 | ATTEMPT=1 85 | BACKOFF_DELAY=2 86 | 87 | JWT_OUTPUT="" 88 | while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do 89 | echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Fetching JWT SVID..." 90 | 91 | JWT_OUTPUT=$(cd "$SPIRE_DIR" && docker compose exec spire-agent /opt/spire/bin/spire-agent api fetch jwt \ 92 | --audience "$AUDIENCE" \ 93 | --spiffeID "$WORKLOAD_SPIFFE_ID" \ 94 | --socketPath /opt/spire/sockets/workload_api.sock 2>&1) 95 | 96 | # Check if the command was successful and JWT was returned 97 | if [ $? -eq 0 ] && echo "$JWT_OUTPUT" | grep -q "token("; then 98 | echo "✅ Successfully fetched JWT SVID on attempt $ATTEMPT" 99 | break 100 | else 101 | echo "⚠️ Attempt $ATTEMPT failed. JWT SVID not yet available." 102 | echo "Output: $JWT_OUTPUT" 103 | 104 | if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then 105 | echo "❌ Failed to fetch JWT SVID after $MAX_ATTEMPTS attempts" 106 | echo "This may indicate:" 107 | echo " 1. The workload entry hasn't synced to the agent yet" 108 | echo " 2. The SPIRE agent is not running or accessible" 109 | echo " 3. The workload selector doesn't match (unix:uid:0)" 110 | echo " 4. Network connectivity issues" 111 | exit 1 112 | fi 113 | 114 | echo "⏳ Waiting ${BACKOFF_DELAY} seconds before next attempt..." 115 | sleep $BACKOFF_DELAY 116 | 117 | # Exponential backoff (double the delay, max 30 seconds) 118 | BACKOFF_DELAY=$((BACKOFF_DELAY * 2)) 119 | if [ $BACKOFF_DELAY -gt 30 ]; then 120 | BACKOFF_DELAY=30 121 | fi 122 | 123 | ATTEMPT=$((ATTEMPT + 1)) 124 | fi 125 | done 126 | 127 | echo "SPIRE JWT Output:" 128 | echo "$JWT_OUTPUT" 129 | prompt_continue 130 | 131 | # 3. Extract JWT token 132 | echo 133 | echo "Step 3: Extracting JWT token..." 134 | JWT=$(echo "$JWT_OUTPUT" | awk '/^token\(/ {getline; gsub(/^[[:space:]]+/, ""); gsub(/[[:space:]]+$/, ""); print $0}') 135 | 136 | if [ -z "$JWT" ]; then 137 | echo "❌ Failed to extract JWT token from SPIRE output" 138 | exit 1 139 | fi 140 | 141 | echo "✅ JWT token extracted (length: ${#JWT} characters)" 142 | echo "JWT starts with: ${JWT:0:50}..." 143 | 144 | # 4. Decode and display JWT for verification 145 | decode_jwt() { 146 | jwt="$1" 147 | header=$(echo "$jwt" | cut -d. -f1 | tr '_-' '/+' | awk '{ l=length($0)%4; if(l>0) { printf "%s", $0; for(i=1;i<=4-l;i++) printf "="; print "" } else print $0 }' | base64 -d 2>/dev/null | jq .) 148 | payload=$(echo "$jwt" | cut -d. -f2 | tr '_-' '/+' | awk '{ l=length($0)%4; if(l>0) { printf "%s", $0; for(i=1;i<=4-l;i++) printf "="; print "" } else print $0 }' | base64 -d 2>/dev/null | jq .) 149 | echo "Header:" 150 | echo "$header" 151 | echo "Claims:" 152 | echo "$payload" 153 | } 154 | 155 | echo 156 | echo "Decoded SPIRE JWT:" 157 | decode_jwt "$JWT" 158 | prompt_continue 159 | 160 | # 5. Make Keycloak client credentials request with JWT assertion 161 | echo 162 | echo "Step 4: Requesting Keycloak access token using JWT client assertion..." 163 | echo "Keycloak Token Endpoint: $KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token" 164 | 165 | # URL encode the JWT for the request 166 | ENCODED_JWT=$(echo "$JWT" | sed 's/+/%2B/g' | sed 's/\//%2F/g' | sed 's/=/%3D/g') 167 | 168 | KEYCLOAK_RESPONSE=$(curl -s -X POST \ 169 | "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token" \ 170 | -H "Content-Type: application/x-www-form-urlencoded" \ 171 | -d "client_id=$CLIENT_ID" \ 172 | -d "grant_type=client_credentials" \ 173 | -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:spiffe-svid-jwt" \ 174 | -d "client_assertion=$JWT" \ 175 | -d "scope=mcp:read mcp:tools mcp:prompts") 176 | 177 | echo "Keycloak Response Status: $?" 178 | echo "Keycloak Response:" 179 | echo "$KEYCLOAK_RESPONSE" | jq . 2>/dev/null || echo "$KEYCLOAK_RESPONSE" 180 | 181 | # 6. Extract and display access token if successful 182 | if echo "$KEYCLOAK_RESPONSE" | grep -q "access_token"; then 183 | echo 184 | echo "✅ SUCCESS: Keycloak access token obtained!" 185 | 186 | ACCESS_TOKEN=$(echo "$KEYCLOAK_RESPONSE" | jq -r '.access_token // empty') 187 | TOKEN_TYPE=$(echo "$KEYCLOAK_RESPONSE" | jq -r '.token_type // empty') 188 | EXPIRES_IN=$(echo "$KEYCLOAK_RESPONSE" | jq -r '.expires_in // empty') 189 | 190 | echo "Token Type: $TOKEN_TYPE" 191 | echo "Expires In: $EXPIRES_IN seconds" 192 | echo "Access Token: $ACCESS_TOKEN" 193 | 194 | # Decode the Keycloak access token 195 | echo 196 | echo "Decoded Keycloak Access Token:" 197 | decode_jwt "$ACCESS_TOKEN" 198 | 199 | # Save token to file for easy use 200 | OUTPUT_FILE="$ORIGINAL_DIR/keycloak_access_token.txt" 201 | echo "$ACCESS_TOKEN" > "$OUTPUT_FILE" 202 | echo 203 | echo "💾 Access token saved to: $OUTPUT_FILE" 204 | echo "You can use it with: export ACCESS_TOKEN=\$(cat $OUTPUT_FILE)" 205 | 206 | else 207 | echo 208 | echo "❌ FAILED: Could not obtain Keycloak access token" 209 | echo "Check that:" 210 | echo "1. Keycloak is running at $KEYCLOAK_URL" 211 | echo "2. Realm '$KEYCLOAK_REALM' exists" 212 | echo "3. Client '$CLIENT_ID' exists and is configured for JWT client authentication" 213 | echo "4. The SPIRE JWT audience matches what Keycloak expects" 214 | fi 215 | 216 | echo 217 | echo "=== Script completed ===" -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": { 3 | "name": "mcp-realm", 4 | "displayName": "MCP Server Realm", 5 | "enabled": true, 6 | "accessTokenLifespan": 3600, 7 | "accessTokenLifespanForImplicitFlow": 1800, 8 | "ssoSessionIdleTimeout": 3600, 9 | "ssoSessionMaxLifespan": 72000, 10 | "offlineSessionIdleTimeout": 2592000, 11 | "attributes": { 12 | "spiffe.trust.domain": "example.org", 13 | "spiffe.jwks.url": "http://spire-oidc-discovery:8443/keys" 14 | } 15 | }, 16 | "authenticationFlows": [ 17 | { 18 | "name": "spiffe-clients", 19 | "description": "SPIFFE-enabled client authentication flow", 20 | "copyFrom": "clients", 21 | "authenticators": [ 22 | { 23 | "providerId": "client-spiffe-jwt", 24 | "requirement": "ALTERNATIVE" 25 | } 26 | ], 27 | "setAsDefault": { 28 | "clientAuthentication": true 29 | } 30 | } 31 | ], 32 | "clients": [ 33 | { 34 | "clientId": "mcp-test-client", 35 | "name": "MCP Test Client", 36 | "type": "public", 37 | "enabled": true, 38 | "standardFlowEnabled": true, 39 | "directAccessGrantsEnabled": true, 40 | "fullScopeAllowed": false, 41 | "assignedScopes": { 42 | "default": ["echo-mcp-server-audience"], 43 | "optional": ["mcp:read", "mcp:tools", "mcp:prompts"] 44 | } 45 | }, 46 | { 47 | "clientId": "spiffe://example.org/mcp-test-client", 48 | "name": "SPIFFE Test Client", 49 | "type": "confidential", 50 | "enabled": true, 51 | "standardFlowEnabled": true, 52 | "serviceAccountsEnabled": true, 53 | "clientAuthenticatorType": "client-spiffe-jwt", 54 | "directAccessGrantsEnabled": true, 55 | "fullScopeAllowed": false, 56 | "assignedScopes": { 57 | "optional": ["mcp:read", "mcp:tools", "mcp:prompts"] 58 | }, 59 | "attributes": { 60 | "use.jwks.url": "true", 61 | "jwks.url": "http://spire-oidc-discovery:8443/keys", 62 | "client.secret.creation.time": "1640995200", 63 | "backchannel.logout.session.required": "false", 64 | "standard.token.exchange.enabled": "true", 65 | "issuer": "http://spire-server:8443" 66 | } 67 | }, 68 | { 69 | "clientId": "gloo-sts", 70 | "name": "Gloo STS", 71 | "type": "confidential", 72 | "enabled": true, 73 | "standardFlowEnabled": true, 74 | "serviceAccountsEnabled": true, 75 | "directAccessGrantsEnabled": false, 76 | "tokenExchange": { 77 | "enabled": true 78 | }, 79 | "clientSecret": "PLOs4j6ti521kb5ZVVVwi5GWi9eDYTwq", 80 | "webOrigins": ["http://localhost:3001"], 81 | "redirectUris": ["http://localhost:3001/auth/callback"], 82 | "protocolMappers": [ 83 | { 84 | "name": "may_act_mapper", 85 | "type": "oidc-hardcoded-claim-mapper", 86 | "protocol": "openid-connect", 87 | "config": { 88 | "claim.name": "may_act", 89 | "claim.value": "{\"sub\":\"spiffe://cluster.local/ns/default/sa/supply-chain-agent\"}", 90 | "jsonType.label": "JSON", 91 | "id.token.claim": "false", 92 | "access.token.claim": "true", 93 | "userinfo.token.claim": "false", 94 | "access.tokenResponse.claim": "false" 95 | } 96 | } 97 | ] 98 | }, 99 | { 100 | "clientId": "echo-mcp-server", 101 | "name": "Echo MCP Server", 102 | "type": "confidential", 103 | "enabled": true, 104 | "standardFlowEnabled": false, 105 | "directAccessGrantsEnabled": false, 106 | "tokenExchange": { 107 | "enabled": true 108 | }, 109 | "clientSecret": "PLOs4j6ti521kb5ZVVVwi5GWi9eDYTwq", 110 | "roles": [ 111 | { 112 | "name": "tools", 113 | "description": "Can call tools" 114 | }, 115 | { 116 | "name": "prompts", 117 | "description": "Can call prompts" 118 | }, 119 | { 120 | "name": "read-only", 121 | "description": "Can read resources" 122 | } 123 | ], 124 | "protocolMappers": [ 125 | { 126 | "name": "mcp_server_claim", 127 | "type": "oidc-hardcoded-claim-mapper", 128 | "protocol": "openid-connect", 129 | "config": { 130 | "claim.name": "mcp.server.type", 131 | "claim.value": "echo-server", 132 | "jsonType.label": "String", 133 | "id.token.claim": "false", 134 | "access.token.claim": "true", 135 | "userinfo.token.claim": "false", 136 | "access.tokenResponse.claim": "false" 137 | } 138 | }, 139 | { 140 | "name": "mcp_capabilities_claim", 141 | "type": "oidc-hardcoded-claim-mapper", 142 | "protocol": "openid-connect", 143 | "config": { 144 | "claim.name": "mcp.capabilities", 145 | "claim.value": "[\"tools\", \"prompts\", \"resources\"]", 146 | "jsonType.label": "JSON", 147 | "id.token.claim": "false", 148 | "access.token.claim": "true", 149 | "userinfo.token.claim": "false", 150 | "access.tokenResponse.claim": "false" 151 | } 152 | } 153 | ] 154 | } 155 | ], 156 | "realmRoles": [ 157 | { 158 | "name": "admin", 159 | "description": "Realm administrator with full access" 160 | }, 161 | { 162 | "name": "user", 163 | "description": "Standard user role" 164 | }, 165 | { 166 | "name": "auditor", 167 | "description": "Read-only access for auditing purposes" 168 | } 169 | ], 170 | "clientScopes": [ 171 | { 172 | "name": "echo-mcp-server-audience", 173 | "description": "Adds echo-mcp-server to token audience", 174 | "protocol": "openid-connect", 175 | "attributes": { 176 | "include.in.token.scope": "true", 177 | "display.on.consent.screen": "false" 178 | }, 179 | "mappers": [ 180 | { 181 | "name": "echo-mcp-server-mapper", 182 | "type": "oidc-audience-mapper", 183 | "config": { 184 | "included.client.audience": "echo-mcp-server", 185 | "id.token.claim": "false", 186 | "access.token.claim": "true" 187 | } 188 | } 189 | ] 190 | }, 191 | { 192 | "name": "mcp:read", 193 | "description": "Read access to MCP resources", 194 | "protocol": "openid-connect", 195 | "attributes": { 196 | "include.in.token.scope": "true", 197 | "display.on.consent.screen": "true" 198 | }, 199 | "realmRoles": ["user", "auditor"], 200 | "roles": [ 201 | { 202 | "client": "echo-mcp-server", 203 | "role": "read-only" 204 | } 205 | ] 206 | }, 207 | { 208 | "name": "mcp:tools", 209 | "description": "Access to execute MCP tools", 210 | "protocol": "openid-connect", 211 | "attributes": { 212 | "include.in.token.scope": "true", 213 | "display.on.consent.screen": "true" 214 | }, 215 | "realmRoles": ["admin", "user"], 216 | "roles": [ 217 | { 218 | "client": "echo-mcp-server", 219 | "role": "tools" 220 | } 221 | ] 222 | }, 223 | { 224 | "name": "mcp:prompts", 225 | "description": "Access to MCP prompts", 226 | "protocol": "openid-connect", 227 | "attributes": { 228 | "include.in.token.scope": "true", 229 | "display.on.consent.screen": "true" 230 | }, 231 | "roles": [ 232 | { 233 | "client": "echo-mcp-server", 234 | "role": "prompts" 235 | } 236 | ] 237 | } 238 | ], 239 | "users": [ 240 | { 241 | "username": "mcp-admin", 242 | "email": "admin@mcp.example.com", 243 | "firstName": "MCP", 244 | "lastName": "Admin", 245 | "enabled": true, 246 | "emailVerified": true, 247 | "password": "admin123", 248 | "temporary": false, 249 | "realmRoles": ["admin", "user"], 250 | "clientRoles": { 251 | "echo-mcp-server": ["tools", "prompts", "read-only"] 252 | } 253 | }, 254 | { 255 | "username": "mcp-user", 256 | "email": "user@mcp.example.com", 257 | "firstName": "MCP", 258 | "lastName": "User", 259 | "enabled": true, 260 | "emailVerified": true, 261 | "password": "user123", 262 | "temporary": false, 263 | "realmRoles": ["user"], 264 | "clientRoles": { 265 | "echo-mcp-server": ["tools", "read-only"] 266 | } 267 | }, 268 | { 269 | "username": "mcp-readonly", 270 | "email": "readonly@mcp.example.com", 271 | "firstName": "MCP", 272 | "lastName": "ReadOnly", 273 | "enabled": true, 274 | "emailVerified": true, 275 | "password": "readonly123", 276 | "temporary": false, 277 | "realmRoles": ["auditor"], 278 | "clientRoles": { 279 | "echo-mcp-server": ["read-only"] 280 | } 281 | } 282 | ] 283 | } -------------------------------------------------------------------------------- /keycloak/boot_keycloak.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Boot Keycloak: Complete Keycloak Setup and Configuration 4 | 5 | This script combines Docker management and Keycloak configuration into a single 6 | streamlined process. It will: 7 | 1. Start Keycloak using Docker Compose 8 | 2. Wait for Keycloak to be ready 9 | 3. Configure Keycloak using setup_keycloak.py 10 | 4. Verify the setup was successful 11 | 12 | Usage: 13 | python boot_keycloak.py [--config CONFIG_FILE] [--url KEYCLOAK_URL] [--summary] [--verbose] 14 | """ 15 | 16 | import subprocess 17 | import sys 18 | import time 19 | import requests 20 | import json 21 | import os 22 | import argparse 23 | from pathlib import Path 24 | from typing import Dict, Any, Optional, List 25 | 26 | # Configuration 27 | DEFAULT_KEYCLOAK_URL = "http://localhost:8080" 28 | DEFAULT_CONFIG_FILE = "config.json" 29 | DEFAULT_ADMIN_USERNAME = "admin" 30 | DEFAULT_ADMIN_PASSWORD = "admin" 31 | 32 | class Colors: 33 | """ANSI color codes for terminal output.""" 34 | RED = '\033[0;31m' 35 | GREEN = '\033[0;32m' 36 | YELLOW = '\033[1;33m' 37 | BLUE = '\033[0;34m' 38 | PURPLE = '\033[0;35m' 39 | CYAN = '\033[0;36m' 40 | WHITE = '\033[1;37m' 41 | NC = '\033[0m' # No Color 42 | 43 | def log(message: str, level: str = "INFO", verbose: bool = False): 44 | """Log messages with color coding.""" 45 | colors = { 46 | 'INFO': f'{Colors.BLUE}ℹ️{Colors.NC}', 47 | 'SUCCESS': f'{Colors.GREEN}✅{Colors.NC}', 48 | 'WARNING': f'{Colors.YELLOW}⚠️{Colors.NC}', 49 | 'ERROR': f'{Colors.RED}❌{Colors.NC}', 50 | 'VERBOSE': f'{Colors.CYAN}🔍{Colors.NC}' 51 | } 52 | 53 | if level == "VERBOSE" and not verbose: 54 | return 55 | 56 | color = colors.get(level, colors['INFO']) 57 | print(f"{color} {message}") 58 | 59 | def run_command(command: str, cwd: Optional[Path] = None, check: bool = True, verbose: bool = False) -> subprocess.CompletedProcess: 60 | """Run a shell command and return the result.""" 61 | log(f"Running: {command}", verbose=verbose) 62 | try: 63 | result = subprocess.run( 64 | command, 65 | shell=True, 66 | cwd=cwd, 67 | capture_output=True, 68 | text=True, 69 | check=check 70 | ) 71 | if result.stdout and verbose: 72 | log(f"Output: {result.stdout.strip()}", "VERBOSE") 73 | if result.stderr and verbose: 74 | log(f"Stderr: {result.stderr.strip()}", "VERBOSE") 75 | return result 76 | except subprocess.CalledProcessError as e: 77 | log(f"Command failed with exit code {e.returncode}", "ERROR") 78 | if e.stderr: 79 | log(f"Error: {e.stderr}", "ERROR") 80 | if check: 81 | raise 82 | return e 83 | 84 | def check_keycloak_health(keycloak_url: str) -> bool: 85 | """Check if Keycloak is healthy and responding.""" 86 | try: 87 | # Try to access the master realm - this is a reliable way to check if Keycloak is running 88 | response = requests.get(f"{keycloak_url}/realms/master", timeout=10) 89 | if response.status_code == 200: 90 | log("Keycloak health check passed", "SUCCESS") 91 | return True 92 | else: 93 | log(f"Keycloak health check failed with status {response.status_code}", "ERROR") 94 | return False 95 | except requests.exceptions.RequestException as e: 96 | log(f"Keycloak health check failed: {e}", "ERROR") 97 | return False 98 | 99 | def wait_for_keycloak(keycloak_url: str, max_attempts: int = 30) -> bool: 100 | """Wait for Keycloak to become available.""" 101 | log("Waiting for Keycloak to become available...") 102 | 103 | for attempt in range(max_attempts): 104 | if check_keycloak_health(keycloak_url): 105 | return True 106 | 107 | log(f"Attempt {attempt + 1}/{max_attempts} - Keycloak not ready yet...") 108 | time.sleep(2) 109 | 110 | log("Keycloak failed to become available within expected time", "ERROR") 111 | return False 112 | 113 | def manage_docker_compose(verbose: bool = False) -> bool: 114 | """Manage Docker Compose for Keycloak.""" 115 | # Use the script's directory as the working directory for Docker operations 116 | keycloak_dir = Path(__file__).parent 117 | 118 | # Check if containers are running and stop them 119 | log("Checking for existing Keycloak containers...") 120 | result = run_command("docker compose ps", cwd=keycloak_dir, check=False, verbose=verbose) 121 | 122 | if "Up" in result.stdout: 123 | log("Stopping existing Keycloak containers...") 124 | run_command("docker compose down", cwd=keycloak_dir, verbose=verbose) 125 | time.sleep(2) # Give containers time to stop 126 | 127 | # Start fresh Keycloak 128 | log("Starting Keycloak with Docker Compose...") 129 | result = run_command("docker compose up -d", cwd=keycloak_dir, verbose=verbose) 130 | 131 | if result.returncode != 0: 132 | log("Failed to start Keycloak containers", "ERROR") 133 | return False 134 | 135 | return True 136 | 137 | def run_setup_keycloak(config_file: str, keycloak_url: str, summary: bool = False, verbose: bool = False) -> bool: 138 | """Run setup_keycloak.py script with the specified configuration.""" 139 | try: 140 | # Build command to run setup_keycloak.py 141 | script_dir = Path(__file__).parent 142 | setup_script = script_dir / "setup_keycloak.py" 143 | 144 | cmd = [ 145 | sys.executable, str(setup_script), 146 | "--config", config_file, 147 | "--url", keycloak_url 148 | ] 149 | 150 | if summary: 151 | cmd.append("--summary") 152 | if verbose: 153 | cmd.append("--verbose") 154 | 155 | log(f"Running Keycloak setup: {' '.join(cmd)}") 156 | 157 | # Run the setup script 158 | result = subprocess.run( 159 | cmd, 160 | cwd=script_dir, 161 | check=True, 162 | capture_output=False, # Let output stream directly to console 163 | text=True 164 | ) 165 | 166 | log("Keycloak setup completed successfully", "SUCCESS") 167 | return True 168 | 169 | except subprocess.CalledProcessError as e: 170 | log(f"Keycloak setup failed: {e}", "ERROR") 171 | return False 172 | except Exception as e: 173 | log(f"Error running Keycloak setup: {e}", "ERROR") 174 | return False 175 | 176 | def load_config(config_file: str) -> Dict[str, Any]: 177 | """Load configuration from JSON file.""" 178 | try: 179 | # Handle both relative and absolute paths 180 | config_path = Path(config_file) 181 | if not config_path.is_absolute(): 182 | # If relative, try to find it relative to the script's directory 183 | script_dir = Path(__file__).parent 184 | config_path = script_dir / config_file 185 | 186 | with open(config_path, 'r') as f: 187 | config = json.load(f) 188 | log(f"Configuration loaded from {config_path}", "SUCCESS") 189 | return config 190 | except Exception as e: 191 | log(f"Failed to load configuration from {config_file}: {e}", "ERROR") 192 | sys.exit(1) 193 | 194 | def main(): 195 | """Main function.""" 196 | parser = argparse.ArgumentParser(description="Boot and configure Keycloak") 197 | parser.add_argument("--config", default=DEFAULT_CONFIG_FILE, help="Path to config file") 198 | parser.add_argument("--url", default=DEFAULT_KEYCLOAK_URL, help="Keycloak URL") 199 | parser.add_argument("--summary", action="store_true", help="Show detailed summary") 200 | parser.add_argument("--verbose", action="store_true", help="Verbose output") 201 | 202 | args = parser.parse_args() 203 | 204 | log("=== Boot Keycloak: Complete Setup and Configuration ===", "INFO") 205 | 206 | try: 207 | # Step 1: Load configuration (just to validate and get realm name) 208 | # If no config file specified, use the default in the keycloak directory 209 | if args.config == DEFAULT_CONFIG_FILE: 210 | script_dir = Path(__file__).parent 211 | config_path = script_dir / DEFAULT_CONFIG_FILE 212 | args.config = str(config_path) 213 | 214 | config = load_config(args.config) 215 | realm_name = config['realm']['name'] 216 | 217 | # Step 2: Manage Docker Compose 218 | if not manage_docker_compose(args.verbose): 219 | log("Failed to manage Docker Compose", "ERROR") 220 | sys.exit(1) 221 | 222 | # Step 3: Wait for Keycloak to be ready 223 | if not wait_for_keycloak(args.url): 224 | log("Keycloak failed to start", "ERROR") 225 | sys.exit(1) 226 | 227 | # Step 4: Run setup_keycloak.py to configure Keycloak 228 | if not run_setup_keycloak(args.config, args.url, args.summary, args.verbose): 229 | log("Failed to setup Keycloak configuration", "ERROR") 230 | sys.exit(1) 231 | 232 | log("=== Boot Keycloak completed successfully! ===", "SUCCESS") 233 | log("Keycloak is ready for MCP integration") 234 | log(f"Keycloak URL: {args.url}") 235 | log(f"Realm: {realm_name}") 236 | 237 | except Exception as e: 238 | log(f"Unexpected error: {e}", "ERROR") 239 | sys.exit(1) 240 | 241 | if __name__ == "__main__": 242 | main() 243 | -------------------------------------------------------------------------------- /spire/test-spiffe-dcr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # SPIFFE DCR SPI Test Script 4 | # Tests the Keycloak SPIFFE Dynamic Client Registration endpoint 5 | 6 | set -e 7 | 8 | # Generate a random UUID for workload identification 9 | generate_workload_uuid() { 10 | if command -v uuidgen &> /dev/null; then 11 | uuidgen | tr '[:upper:]' '[:lower:]' 12 | else 13 | # Fallback UUID generation using /dev/urandom 14 | printf '%08x-%04x-%04x-%04x-%012x\n' \ 15 | $((RANDOM * RANDOM)) \ 16 | $((RANDOM % 65536)) \ 17 | $(((RANDOM % 4096) | 16384)) \ 18 | $(((RANDOM % 16384) | 32768)) \ 19 | $((RANDOM * RANDOM * RANDOM % 281474976710656)) 20 | fi 21 | } 22 | 23 | # Generate workload UUID and set default SPIFFE ID 24 | WORKLOAD_UUID=$(generate_workload_uuid) 25 | 26 | # Configuration (modify these variables for your environment) 27 | KEYCLOAK_BASE_URL="${KEYCLOAK_BASE_URL:-http://localhost:8080}" 28 | REALM_NAME="${REALM_NAME:-mcp-realm}" 29 | TRUST_DOMAIN="${TRUST_DOMAIN:-example.org}" 30 | PARENT_SPIFFE_ID="${PARENT_SPIFFE_ID:-spiffe://example.org/agent}" 31 | SPIFFE_ID="${SPIFFE_ID:-spiffe://${TRUST_DOMAIN}/${WORKLOAD_UUID}}" 32 | CLIENT_NAME="${CLIENT_NAME:-Test SPIFFE Service}" 33 | JWKS_URL="${JWKS_URL:-https://test-service:8443/.well-known/jwks}" 34 | JWT_SVID_TTL="${JWT_SVID_TTL:-60}" 35 | 36 | # Colors for output 37 | RED='\033[0;31m' 38 | GREEN='\033[0;32m' 39 | YELLOW='\033[1;33m' 40 | BLUE='\033[0;34m' 41 | NC='\033[0m' # No Color 42 | 43 | # Function to print colored output 44 | print_status() { 45 | echo -e "${BLUE}[INFO]${NC} $1" 46 | } 47 | 48 | print_success() { 49 | echo -e "${GREEN}[SUCCESS]${NC} $1" 50 | } 51 | 52 | print_warning() { 53 | echo -e "${YELLOW}[WARNING]${NC} $1" 54 | } 55 | 56 | print_error() { 57 | echo -e "${RED}[ERROR]${NC} $1" 58 | } 59 | 60 | # Function to prompt for continuation 61 | prompt_continue() { 62 | if [ "$AUTO_CONTINUE" != true ]; then 63 | read -r -p "Continue (Y/n)? " response 64 | response=${response:-Y} 65 | if [[ "$response" =~ ^[Nn]$ ]]; then 66 | echo "Aborting." 67 | exit 1 68 | fi 69 | fi 70 | } 71 | 72 | # Function to show usage 73 | show_usage() { 74 | echo "Usage: $0 [OPTIONS]" 75 | echo "" 76 | echo "Options:" 77 | echo " --keycloak-url URL Keycloak base URL (default: http://localhost:8080)" 78 | echo " --realm REALM Realm name (default: spiffe-realm)" 79 | echo " --trust-domain DOMAIN SPIFFE trust domain (default: example.org)" 80 | echo " --parent-id ID Parent SPIFFE ID (default: spiffe://example.org/agent)" 81 | echo " --spiffe-id ID SPIFFE ID for testing (default: auto-generated with UUID)" 82 | echo " --client-name NAME Client name (default: Test SPIFFE Service)" 83 | echo " --jwks-url URL JWKS URL (default: https://test-service:8443/.well-known/jwks)" 84 | echo " --jwt-ttl SECONDS JWT SVID TTL in seconds (default: 60)" 85 | echo " --software-statement JWT Use provided JWT instead of generating one" 86 | echo " --use-mock-jwt Use mock JWT instead of SPIRE agent (default: false)" 87 | echo " --no-auto-register Skip automatic workload registration" 88 | echo " --auto-continue Don't prompt for continuation" 89 | echo " --verbose Enable verbose output" 90 | echo " --help Show this help message" 91 | echo "" 92 | echo "Environment Variables:" 93 | echo " KEYCLOAK_BASE_URL Keycloak base URL" 94 | echo " REALM_NAME Realm name" 95 | echo " TRUST_DOMAIN SPIFFE trust domain" 96 | echo " PARENT_SPIFFE_ID Parent SPIFFE ID" 97 | echo " SPIFFE_ID SPIFFE ID" 98 | echo " CLIENT_NAME Client name" 99 | echo " JWKS_URL JWKS URL" 100 | echo " JWT_SVID_TTL JWT SVID TTL in seconds" 101 | echo "" 102 | echo "Examples:" 103 | echo " $0 --keycloak-url https://keycloak.example.com --realm production" 104 | echo " $0 --trust-domain production.com --parent-id spiffe://production.com/node" 105 | echo " $0 --use-mock-jwt --spiffe-id spiffe://example.org/workload/my-service" 106 | echo " $0 --software-statement 'eyJhbGciOiJSUzI1NiIs...'" 107 | } 108 | 109 | # Function to check if docker and SPIRE containers are available 110 | check_spire_environment() { 111 | print_status "Checking SPIRE environment..." 112 | 113 | # Check if docker is available 114 | if ! command -v docker &> /dev/null; then 115 | print_error "Docker is not available. Please install Docker." 116 | return 1 117 | fi 118 | 119 | # Check if SPIRE server container is running 120 | if ! docker ps | grep -q "spire-server"; then 121 | print_error "SPIRE server container is not running. Please start it with 'docker compose up -d spire-server' or 'docker run'" 122 | print_error "Looking for container with 'spire-server' in the name" 123 | return 1 124 | fi 125 | 126 | # Check if SPIRE agent container is running 127 | if ! docker ps | grep -q "spire-agent"; then 128 | print_error "SPIRE agent container is not running. Please start it with 'docker compose up -d spire-agent' or 'docker run'" 129 | print_error "Looking for container with 'spire-agent' in the name" 130 | return 1 131 | fi 132 | 133 | print_success "SPIRE environment is ready" 134 | print_status "Found running containers:" 135 | docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "(spire-server|spire-agent)" || true 136 | return 0 137 | } 138 | 139 | # Function to check and register SPIRE workload entry 140 | check_and_register_workload() { 141 | print_status "Checking/Registering SPIRE workload entry..." 142 | print_status "SPIFFE ID: $SPIFFE_ID" 143 | print_status "Parent ID: $PARENT_SPIFFE_ID" 144 | 145 | # Get the SPIRE server container name 146 | local spire_server_container=$(docker ps --format "{{.Names}}" | grep spire-server | head -n1) 147 | if [ -z "$spire_server_container" ]; then 148 | print_error "Could not find SPIRE server container name" 149 | return 1 150 | fi 151 | 152 | print_status "Using SPIRE server container: $spire_server_container" 153 | 154 | # Check if workload entry exists 155 | if docker exec "$spire_server_container" /opt/spire/bin/spire-server entry show -spiffeID "$SPIFFE_ID" 2>/dev/null | grep -q "Entry ID"; then 156 | print_success "Workload entry already exists." 157 | return 0 158 | fi 159 | 160 | print_status "Registering workload entry..." 161 | if docker exec "$spire_server_container" /opt/spire/bin/spire-server entry create \ 162 | -parentID "$PARENT_SPIFFE_ID" \ 163 | -spiffeID "$SPIFFE_ID" \ 164 | -jwtSVIDTTL "$JWT_SVID_TTL" \ 165 | -selector unix:uid:0; then 166 | print_success "Workload entry created successfully." 167 | return 0 168 | else 169 | print_error "Failed to create workload entry." 170 | return 1 171 | fi 172 | } 173 | 174 | # Function to get SVID from SPIRE agent using docker exec 175 | get_spire_jwt_svid() { 176 | local audience="${KEYCLOAK_BASE_URL}/realms/${REALM_NAME}" 177 | print_status "Fetching JWT SVID from SPIRE..." >&2 178 | print_status "Audience: $audience" >&2 179 | print_status "SPIFFE ID: $SPIFFE_ID" >&2 180 | 181 | # Get the SPIRE agent container name 182 | local spire_agent_container=$(docker ps --format "{{.Names}}" | grep spire-agent | head -n1) 183 | if [ -z "$spire_agent_container" ]; then 184 | print_error "Could not find SPIRE agent container name" >&2 185 | return 1 186 | fi 187 | 188 | print_status "Using SPIRE agent container: $spire_agent_container" >&2 189 | 190 | # Retry logic - sometimes the agent needs time to sync with the server 191 | local max_retries=10 192 | local retry_delay=2 193 | local attempt=1 194 | local jwt_output 195 | 196 | while [ $attempt -le $max_retries ]; do 197 | print_status "Attempt $attempt/$max_retries: Fetching JWT SVID..." >&2 198 | 199 | if jwt_output=$(docker exec "$spire_agent_container" /opt/spire/bin/spire-agent api fetch jwt \ 200 | --audience "$audience" \ 201 | --spiffeID "$SPIFFE_ID" \ 202 | --socketPath /opt/spire/sockets/workload_api.sock 2>/dev/null); then 203 | 204 | # Check if we got a valid response (not empty and contains token info) 205 | if [ -n "$jwt_output" ] && echo "$jwt_output" | grep -q "token"; then 206 | print_success "Successfully obtained JWT SVID on attempt $attempt" >&2 207 | break 208 | else 209 | print_warning "Got empty or invalid response on attempt $attempt" >&2 210 | fi 211 | else 212 | print_warning "Failed to fetch JWT SVID on attempt $attempt" >&2 213 | fi 214 | 215 | if [ $attempt -lt $max_retries ]; then 216 | print_status "Waiting ${retry_delay} seconds before retry..." >&2 217 | sleep $retry_delay 218 | fi 219 | 220 | attempt=$((attempt + 1)) 221 | done 222 | 223 | # Check if we succeeded after all retries 224 | if [ $attempt -gt $max_retries ]; then 225 | print_error "Failed to get JWT SVID after $max_retries attempts" >&2 226 | print_status "This can happen if:" >&2 227 | echo " - The workload entry hasn't synced to the agent yet" >&2 228 | echo " - The SPIFFE ID doesn't match exactly" >&2 229 | echo " - The parent-child relationship is incorrect" >&2 230 | echo " - The agent isn't properly connected to the server" >&2 231 | return 1 232 | fi 233 | 234 | # Process the successful response 235 | if [ -n "$jwt_output" ]; then 236 | 237 | if [ "$VERBOSE" = true ]; then 238 | print_status "SPIRE JWT Output:" >&2 239 | echo "$jwt_output" >&2 240 | fi 241 | 242 | # Extract JWT token from output 243 | local jwt_token 244 | jwt_token=$(echo "$jwt_output" | awk '/^token\(/ {getline; gsub(/^[[:space:]]+/, ""); gsub(/[[:space:]]+$/, ""); print $0}') 245 | 246 | # Debug the extraction 247 | if [ "$VERBOSE" = true ]; then 248 | print_status "Raw JWT extraction result: '$jwt_token'" >&2 249 | print_status "JWT length: ${#jwt_token}" >&2 250 | fi 251 | 252 | if [ -z "$jwt_token" ]; then 253 | print_error "Failed to extract JWT token from SPIRE output" >&2 254 | print_status "Trying alternative extraction methods..." >&2 255 | 256 | # Try alternative extraction - look for lines that look like JWTs 257 | jwt_token=$(echo "$jwt_output" | grep -E '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$' | head -n1 | tr -d ' \t\n\r') 258 | 259 | if [ -z "$jwt_token" ]; then 260 | print_error "Alternative extraction also failed" >&2 261 | print_status "SPIRE output for debugging:" >&2 262 | echo "$jwt_output" | cat -n >&2 263 | return 1 264 | else 265 | print_success "Alternative extraction succeeded" >&2 266 | fi 267 | fi 268 | 269 | print_success "JWT SVID obtained successfully (length: ${#jwt_token} characters)" >&2 270 | echo "$jwt_token" 271 | return 0 272 | else 273 | print_error "Failed to get JWT SVID from SPIRE agent" >&2 274 | return 1 275 | fi 276 | } 277 | 278 | # Function to generate a mock JWT for testing 279 | generate_mock_jwt() { 280 | local iss="spiffe://${TRUST_DOMAIN}" 281 | local sub="${SPIFFE_ID}" 282 | local aud="${KEYCLOAK_BASE_URL}/realms/${REALM_NAME}" 283 | local current_time=$(date +%s) 284 | local exp_time=$((current_time + 3600)) # 1 hour from now 285 | 286 | print_status "Generating mock JWT software statement..." 287 | 288 | # JWT Header 289 | local header='{"alg":"RS256","typ":"JWT"}' 290 | local header_b64=$(echo -n "$header" | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n') 291 | 292 | # JWT Payload 293 | local payload=$(cat <&2 313 | echo "$mock_jwt" 314 | } 315 | 316 | # Function to decode and display JWT 317 | decode_jwt() { 318 | local jwt="$1" 319 | local header_b64=$(echo "$jwt" | cut -d. -f1) 320 | local payload_b64=$(echo "$jwt" | cut -d. -f2) 321 | 322 | # Add padding if needed for header 323 | local header_padding=$((4 - ${#header_b64} % 4)) 324 | if [ $header_padding -ne 4 ]; then 325 | header_b64="${header_b64}$(printf '=%.0s' $(seq 1 $header_padding))" 326 | fi 327 | local header=$(echo "$header_b64" | tr '_-' '/+' | base64 -d 2>/dev/null || echo "Could not decode header") 328 | 329 | # Add padding if needed for payload 330 | local payload_padding=$((4 - ${#payload_b64} % 4)) 331 | if [ $payload_padding -ne 4 ]; then 332 | payload_b64="${payload_b64}$(printf '=%.0s' $(seq 1 $payload_padding))" 333 | fi 334 | local payload=$(echo "$payload_b64" | tr '_-' '/+' | base64 -d 2>/dev/null || echo "Could not decode payload") 335 | 336 | echo "Header:" 337 | echo "$header" | jq '.' 2>/dev/null || echo "$header" 338 | echo "Claims:" 339 | echo "$payload" | jq '.' 2>/dev/null || echo "$payload" 340 | } 341 | 342 | # Function to validate JWT structure 343 | validate_jwt_structure() { 344 | local jwt="$1" 345 | 346 | # Clean the JWT of any whitespace/newlines 347 | jwt=$(echo "$jwt" | tr -d ' \t\n\r') 348 | 349 | if [ "$VERBOSE" = true ]; then 350 | print_status "Validating JWT: '${jwt:0:50}...'" 351 | print_status "JWT length after cleaning: ${#jwt}" 352 | fi 353 | 354 | if [[ ! "$jwt" =~ ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]]; then 355 | print_error "Invalid JWT format. Expected format: header.payload.signature" 356 | print_error "Received: '${jwt:0:100}...'" 357 | return 1 358 | fi 359 | 360 | if [ "$VERBOSE" = true ]; then 361 | print_status "JWT Structure Validation:" 362 | decode_jwt "$jwt" 363 | fi 364 | 365 | return 0 366 | } 367 | 368 | # Function to make the DCR request 369 | make_dcr_request() { 370 | local software_statement="$1" 371 | local endpoint="${KEYCLOAK_BASE_URL}/realms/${REALM_NAME}/clients-registrations/spiffe-dcr/register" 372 | 373 | print_status "Making DCR request to: $endpoint" 374 | 375 | # Prepare request body 376 | local request_body=$(cat </dev/null || echo "$response_body" 409 | return 0 410 | else 411 | print_error "Client registration failed!" 412 | echo "Response body:" 413 | echo "$response_body" | jq '.' 2>/dev/null || echo "$response_body" 414 | return 1 415 | fi 416 | } 417 | 418 | # Function to test endpoint availability 419 | test_endpoint_availability() { 420 | local base_endpoint="${KEYCLOAK_BASE_URL}/realms/${REALM_NAME}" 421 | 422 | print_status "Testing Keycloak endpoint availability..." 423 | 424 | if curl -s -f "$base_endpoint" > /dev/null; then 425 | print_success "Keycloak realm endpoint is accessible" 426 | else 427 | print_error "Cannot reach Keycloak realm endpoint: $base_endpoint" 428 | print_warning "Please check:" 429 | echo " - Keycloak is running and accessible" 430 | echo " - Realm name is correct: $REALM_NAME" 431 | echo " - URL is correct: $KEYCLOAK_BASE_URL" 432 | return 1 433 | fi 434 | } 435 | 436 | # Function to run pre-flight checks 437 | run_preflight_checks() { 438 | print_status "Running pre-flight checks..." 439 | 440 | # Check if jq is available 441 | if ! command -v jq &> /dev/null; then 442 | print_warning "jq not found. JSON output will not be formatted." 443 | fi 444 | 445 | # Check if curl is available 446 | if ! command -v curl &> /dev/null; then 447 | print_error "curl is required but not found. Please install curl." 448 | return 1 449 | fi 450 | 451 | # Test endpoint availability 452 | test_endpoint_availability || return 1 453 | 454 | print_success "Pre-flight checks passed" 455 | } 456 | 457 | # Parse command line arguments 458 | VERBOSE=false 459 | USE_SPIRE_AGENT=true 460 | CUSTOM_SOFTWARE_STATEMENT="" 461 | AUTO_CONTINUE=false 462 | NO_AUTO_REGISTER=false 463 | 464 | while [[ $# -gt 0 ]]; do 465 | case $1 in 466 | --keycloak-url) 467 | KEYCLOAK_BASE_URL="$2" 468 | shift 2 469 | ;; 470 | --realm) 471 | REALM_NAME="$2" 472 | shift 2 473 | ;; 474 | --trust-domain) 475 | TRUST_DOMAIN="$2" 476 | shift 2 477 | ;; 478 | --parent-id) 479 | PARENT_SPIFFE_ID="$2" 480 | shift 2 481 | ;; 482 | --spiffe-id) 483 | SPIFFE_ID="$2" 484 | shift 2 485 | ;; 486 | --client-name) 487 | CLIENT_NAME="$2" 488 | shift 2 489 | ;; 490 | --jwks-url) 491 | JWKS_URL="$2" 492 | shift 2 493 | ;; 494 | --jwt-ttl) 495 | JWT_SVID_TTL="$2" 496 | shift 2 497 | ;; 498 | --software-statement) 499 | CUSTOM_SOFTWARE_STATEMENT="$2" 500 | shift 2 501 | ;; 502 | --use-mock-jwt) 503 | USE_SPIRE_AGENT=false 504 | shift 505 | ;; 506 | --no-auto-register) 507 | NO_AUTO_REGISTER=true 508 | shift 509 | ;; 510 | --auto-continue) 511 | AUTO_CONTINUE=true 512 | shift 513 | ;; 514 | --verbose) 515 | VERBOSE=true 516 | shift 517 | ;; 518 | --help) 519 | show_usage 520 | exit 0 521 | ;; 522 | *) 523 | print_error "Unknown option: $1" 524 | show_usage 525 | exit 1 526 | ;; 527 | esac 528 | done 529 | 530 | # Main script execution 531 | main() { 532 | echo "=== SPIFFE DCR SPI Test Script ===" 533 | echo "" 534 | 535 | print_status "Configuration:" 536 | echo " Keycloak URL: $KEYCLOAK_BASE_URL" 537 | echo " Realm: $REALM_NAME" 538 | echo " Trust Domain: $TRUST_DOMAIN" 539 | echo " Parent SPIFFE ID: $PARENT_SPIFFE_ID" 540 | echo " SPIFFE ID: $SPIFFE_ID" 541 | echo " Client Name: $CLIENT_NAME" 542 | echo " JWKS URL: $JWKS_URL" 543 | echo " JWT TTL: $JWT_SVID_TTL seconds" 544 | echo " Use SPIRE Agent: $USE_SPIRE_AGENT" 545 | echo " Auto Register: $([ "$NO_AUTO_REGISTER" = true ] && echo "false" || echo "true")" 546 | echo "" 547 | 548 | # Run pre-flight checks 549 | print_status "Step 1: Running pre-flight checks" 550 | run_preflight_checks || exit 1 551 | print_success "Pre-flight checks completed successfully" 552 | prompt_continue 553 | echo "" 554 | 555 | # Check and register SPIRE workload entry (if using SPIRE and not disabled) 556 | if [ "$USE_SPIRE_AGENT" = true ] && [ "$NO_AUTO_REGISTER" != true ] && [ -z "$CUSTOM_SOFTWARE_STATEMENT" ]; then 557 | print_status "Step 2: SPIRE Environment & Workload Registration" 558 | check_spire_environment || exit 1 559 | check_and_register_workload || exit 1 560 | print_success "SPIRE workload registration completed" 561 | prompt_continue 562 | echo "" 563 | elif [ "$NO_AUTO_REGISTER" = true ]; then 564 | print_warning "Skipping automatic workload registration due to --no-auto-register flag." 565 | prompt_continue 566 | echo "" 567 | fi 568 | 569 | # Get or generate software statement 570 | local software_statement 571 | 572 | if [ -n "$CUSTOM_SOFTWARE_STATEMENT" ]; then 573 | print_status "Step 3: Using provided software statement" 574 | software_statement="$CUSTOM_SOFTWARE_STATEMENT" 575 | print_success "Using custom software statement provided" 576 | prompt_continue 577 | elif [ "$USE_SPIRE_AGENT" = true ]; then 578 | print_status "Step 3: Fetching JWT SVID from SPIRE agent" 579 | software_statement=$(get_spire_jwt_svid) || exit 1 580 | 581 | echo "" 582 | print_status "Decoded SPIRE JWT:" 583 | decode_jwt "$software_statement" 584 | 585 | prompt_continue 586 | else 587 | print_status "Step 3: Generating mock software statement" 588 | software_statement=$(generate_mock_jwt) 589 | print_warning "Using mock JWT - this will likely fail signature validation" 590 | print_warning "Use default behavior for real SPIRE SVID or --software-statement with valid JWT" 591 | prompt_continue 592 | fi 593 | 594 | echo "" 595 | 596 | # Validate JWT structure 597 | print_status "Step 4: Validating JWT structure" 598 | validate_jwt_structure "$software_statement" || exit 1 599 | print_success "JWT structure validation passed" 600 | prompt_continue 601 | echo "" 602 | 603 | # Make DCR request 604 | print_status "Step 5: Making Dynamic Client Registration request" 605 | make_dcr_request "$software_statement" 606 | local exit_code=$? 607 | 608 | echo "" 609 | if [ $exit_code -eq 0 ]; then 610 | print_success "🎉 Test completed successfully!" 611 | print_status "The SPIFFE workload has been registered and DCR request succeeded." 612 | else 613 | print_error "❌ Test failed. Check the error messages above." 614 | print_status "Common issues:" 615 | echo " - SPIRE containers not running" 616 | echo " - Keycloak SPIFFE DCR SPI not configured" 617 | echo " - Network connectivity issues" 618 | echo " - JWT signature validation failures" 619 | fi 620 | 621 | exit $exit_code 622 | } 623 | 624 | # Run main function 625 | main "$@" -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.8" 4 | resolution-markers = [ 5 | "python_full_version >= '3.9'", 6 | "python_full_version < '3.9'", 7 | ] 8 | 9 | [[package]] 10 | name = "certifi" 11 | version = "2025.8.3" 12 | source = { registry = "https://pypi.org/simple" } 13 | sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } 14 | wheels = [ 15 | { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, 16 | ] 17 | 18 | [[package]] 19 | name = "charset-normalizer" 20 | version = "3.4.3" 21 | source = { registry = "https://pypi.org/simple" } 22 | sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695 }, 25 | { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153 }, 26 | { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428 }, 27 | { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627 }, 28 | { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388 }, 29 | { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077 }, 30 | { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631 }, 31 | { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210 }, 32 | { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739 }, 33 | { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825 }, 34 | { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452 }, 35 | { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 }, 36 | { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 }, 37 | { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 }, 38 | { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 }, 39 | { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 }, 40 | { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 }, 41 | { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 }, 42 | { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 }, 43 | { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 }, 44 | { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 }, 45 | { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 }, 46 | { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, 47 | { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, 48 | { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, 49 | { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, 50 | { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, 51 | { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, 52 | { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, 53 | { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, 54 | { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, 55 | { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, 56 | { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, 57 | { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, 58 | { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, 59 | { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, 60 | { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, 61 | { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, 62 | { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, 63 | { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, 64 | { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, 65 | { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, 66 | { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, 67 | { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, 68 | { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, 69 | { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, 70 | { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, 71 | { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, 72 | { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, 73 | { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, 74 | { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, 75 | { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, 76 | { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, 77 | { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, 78 | { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, 79 | { url = "https://files.pythonhosted.org/packages/22/82/63a45bfc36f73efe46731a3a71cb84e2112f7e0b049507025ce477f0f052/charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", size = 198805 }, 80 | { url = "https://files.pythonhosted.org/packages/0c/52/8b0c6c3e53f7e546a5e49b9edb876f379725914e1130297f3b423c7b71c5/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", size = 142862 }, 81 | { url = "https://files.pythonhosted.org/packages/59/c0/a74f3bd167d311365e7973990243f32c35e7a94e45103125275b9e6c479f/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", size = 155104 }, 82 | { url = "https://files.pythonhosted.org/packages/1a/79/ae516e678d6e32df2e7e740a7be51dc80b700e2697cb70054a0f1ac2c955/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", size = 152598 }, 83 | { url = "https://files.pythonhosted.org/packages/00/bd/ef9c88464b126fa176f4ef4a317ad9b6f4d30b2cffbc43386062367c3e2c/charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", size = 147391 }, 84 | { url = "https://files.pythonhosted.org/packages/7a/03/cbb6fac9d3e57f7e07ce062712ee80d80a5ab46614684078461917426279/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", size = 145037 }, 85 | { url = "https://files.pythonhosted.org/packages/64/d1/f9d141c893ef5d4243bc75c130e95af8fd4bc355beff06e9b1e941daad6e/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", size = 156425 }, 86 | { url = "https://files.pythonhosted.org/packages/c5/35/9c99739250742375167bc1b1319cd1cec2bf67438a70d84b2e1ec4c9daa3/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", size = 153734 }, 87 | { url = "https://files.pythonhosted.org/packages/50/10/c117806094d2c956ba88958dab680574019abc0c02bcf57b32287afca544/charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", size = 148551 }, 88 | { url = "https://files.pythonhosted.org/packages/61/c5/dc3ba772489c453621ffc27e8978a98fe7e41a93e787e5e5bde797f1dddb/charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", size = 98459 }, 89 | { url = "https://files.pythonhosted.org/packages/05/35/bb59b1cd012d7196fc81c2f5879113971efc226a63812c9cf7f89fe97c40/charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", size = 105887 }, 90 | { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520 }, 91 | { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307 }, 92 | { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448 }, 93 | { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758 }, 94 | { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487 }, 95 | { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054 }, 96 | { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703 }, 97 | { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096 }, 98 | { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852 }, 99 | { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840 }, 100 | { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438 }, 101 | { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, 102 | ] 103 | 104 | [[package]] 105 | name = "idna" 106 | version = "3.10" 107 | source = { registry = "https://pypi.org/simple" } 108 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 109 | wheels = [ 110 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 111 | ] 112 | 113 | [[package]] 114 | name = "keycloak-agent-identity" 115 | version = "0.1.0" 116 | source = { editable = "." } 117 | dependencies = [ 118 | { name = "requests" }, 119 | ] 120 | 121 | [package.metadata] 122 | requires-dist = [{ name = "requests", specifier = ">=2.25.0" }] 123 | 124 | [package.metadata.requires-dev] 125 | dev = [] 126 | 127 | [[package]] 128 | name = "requests" 129 | version = "2.32.4" 130 | source = { registry = "https://pypi.org/simple" } 131 | dependencies = [ 132 | { name = "certifi" }, 133 | { name = "charset-normalizer" }, 134 | { name = "idna" }, 135 | { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, 136 | { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, 137 | ] 138 | sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } 139 | wheels = [ 140 | { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, 141 | ] 142 | 143 | [[package]] 144 | name = "urllib3" 145 | version = "2.2.3" 146 | source = { registry = "https://pypi.org/simple" } 147 | resolution-markers = [ 148 | "python_full_version < '3.9'", 149 | ] 150 | sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } 151 | wheels = [ 152 | { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, 153 | ] 154 | 155 | [[package]] 156 | name = "urllib3" 157 | version = "2.5.0" 158 | source = { registry = "https://pypi.org/simple" } 159 | resolution-markers = [ 160 | "python_full_version >= '3.9'", 161 | ] 162 | sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } 163 | wheels = [ 164 | { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, 165 | ] 166 | -------------------------------------------------------------------------------- /keycloak/setup_keycloak.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Keycloak Token Exchange Setup Script 5 | 6 | This script automates the complete setup for Standard Token Exchange in Keycloak 26.2.5 7 | using configuration from a JSON file. 8 | 9 | Usage: 10 | python setup_keycloak.py --config config.json --url http://localhost:8080 11 | 12 | Requirements: 13 | pip install requests 14 | """ 15 | 16 | import argparse 17 | import json 18 | import sys 19 | import time 20 | from typing import Dict, List, Optional, Any 21 | import requests 22 | from urllib.parse import urljoin 23 | 24 | 25 | class Colors: 26 | """ANSI color codes for terminal output.""" 27 | RED = '\033[0;31m' 28 | GREEN = '\033[0;32m' 29 | YELLOW = '\033[1;33m' 30 | BLUE = '\033[0;34m' 31 | PURPLE = '\033[0;35m' 32 | CYAN = '\033[0;36m' 33 | WHITE = '\033[1;37m' 34 | NC = '\033[0m' # No Color 35 | 36 | 37 | class KeycloakSetup: 38 | """Keycloak setup automation class.""" 39 | 40 | def __init__(self, keycloak_url: str, admin_username: str = "admin", admin_password: str = "admin"): 41 | self.keycloak_url = keycloak_url.rstrip('/') 42 | self.admin_username = admin_username 43 | self.admin_password = admin_password 44 | self.admin_token = None 45 | self.session = requests.Session() 46 | self.debug = False 47 | self.admin_base_url = None # Will be detected during setup 48 | 49 | def log(self, level: str, message: str): 50 | """Log messages with color coding.""" 51 | colors = { 52 | 'INFO': Colors.BLUE, 53 | 'SUCCESS': Colors.GREEN, 54 | 'WARNING': Colors.YELLOW, 55 | 'ERROR': Colors.RED, 56 | 'DEBUG': Colors.PURPLE 57 | } 58 | color = colors.get(level, Colors.WHITE) 59 | 60 | # Only print DEBUG messages if debug is enabled 61 | if level == 'DEBUG' and not self.debug: 62 | return 63 | 64 | print(f"{color}[{level}]{Colors.NC} {message}") 65 | 66 | def get_admin_token(self) -> bool: 67 | """Get admin access token.""" 68 | self.log('INFO', 'Getting admin access token...') 69 | 70 | # Try multiple token endpoint paths 71 | token_paths = [ 72 | "/realms/master/protocol/openid-connect/token", 73 | "/auth/realms/master/protocol/openid-connect/token" 74 | ] 75 | 76 | for token_path in token_paths: 77 | try: 78 | token_url = f"{self.keycloak_url}{token_path}" 79 | if self.debug: 80 | self.log('DEBUG', f'Trying token endpoint: {token_url}') 81 | 82 | response = self.session.post( 83 | token_url, 84 | data={ 85 | "grant_type": "password", 86 | "client_id": "admin-cli", 87 | "username": self.admin_username, 88 | "password": self.admin_password 89 | }, 90 | headers={"Content-Type": "application/x-www-form-urlencoded"} 91 | ) 92 | 93 | if response.status_code == 200: 94 | token_data = response.json() 95 | self.admin_token = token_data.get('access_token') 96 | 97 | if not self.admin_token: 98 | continue 99 | 100 | self.session.headers.update({'Authorization': f'Bearer {self.admin_token}'}) 101 | self.log('SUCCESS', 'Admin token obtained') 102 | if self.debug: 103 | self.log('DEBUG', f'Working token endpoint: {token_url}') 104 | return True 105 | elif self.debug: 106 | self.log('DEBUG', f'Token endpoint failed with status: {response.status_code}') 107 | 108 | except requests.exceptions.RequestException as e: 109 | if self.debug: 110 | self.log('DEBUG', f'Token endpoint error: {e}') 111 | continue 112 | 113 | self.log('ERROR', 'Failed to get admin token from any endpoint') 114 | return False 115 | 116 | def detect_admin_base_url(self) -> bool: 117 | """Detect the correct admin API base URL.""" 118 | admin_paths = ["/admin", "/auth/admin"] 119 | 120 | for admin_path in admin_paths: 121 | try: 122 | test_url = f"{self.keycloak_url}{admin_path}/realms" 123 | if self.debug: 124 | self.log('DEBUG', f'Testing admin API at: {test_url}') 125 | 126 | response = self.session.get(test_url) 127 | if response.status_code == 200: 128 | self.admin_base_url = f"{self.keycloak_url}{admin_path}" 129 | if self.debug: 130 | self.log('DEBUG', f'Admin API base URL: {self.admin_base_url}') 131 | return True 132 | elif self.debug: 133 | self.log('DEBUG', f'Admin API test failed with status: {response.status_code}') 134 | 135 | except requests.exceptions.RequestException as e: 136 | if self.debug: 137 | self.log('DEBUG', f'Admin API test error: {e}') 138 | continue 139 | 140 | self.log('ERROR', 'Could not detect admin API base URL') 141 | return False 142 | 143 | def create_realm(self, realm_config: Dict[str, Any]) -> bool: 144 | """Create a realm.""" 145 | realm_name = realm_config['name'] 146 | self.log('INFO', f'Creating realm: {realm_name}...') 147 | 148 | if not self.admin_base_url: 149 | if not self.detect_admin_base_url(): 150 | return False 151 | 152 | try: 153 | # Check if realm exists 154 | response = self.session.get(f"{self.admin_base_url}/realms/{realm_name}") 155 | if response.status_code == 200: 156 | self.log('WARNING', f'Realm {realm_name} already exists, updating attributes...') 157 | return self.update_realm_attributes(realm_name, realm_config) 158 | 159 | # Create realm - use proper Keycloak realm structure 160 | realm_data = { 161 | "realm": realm_config['name'], 162 | "displayName": realm_config.get('displayName', realm_config['name']), 163 | "enabled": realm_config.get('enabled', True) 164 | } 165 | 166 | # Add optional settings only if they're reasonable values 167 | if 'accessTokenLifespan' in realm_config: 168 | realm_data["accessTokenLifespan"] = realm_config['accessTokenLifespan'] 169 | if 'accessTokenLifespanForImplicitFlow' in realm_config: 170 | realm_data["accessTokenLifespanForImplicitFlow"] = realm_config['accessTokenLifespanForImplicitFlow'] 171 | if 'ssoSessionIdleTimeout' in realm_config: 172 | realm_data["ssoSessionIdleTimeout"] = realm_config['ssoSessionIdleTimeout'] 173 | if 'ssoSessionMaxLifespan' in realm_config: 174 | realm_data["ssoSessionMaxLifespan"] = realm_config['ssoSessionMaxLifespan'] 175 | if 'offlineSessionIdleTimeout' in realm_config: 176 | realm_data["offlineSessionIdleTimeout"] = realm_config['offlineSessionIdleTimeout'] 177 | 178 | # Add realm attributes if specified 179 | if 'attributes' in realm_config: 180 | realm_data["attributes"] = {} 181 | for key, value in realm_config['attributes'].items(): 182 | realm_data["attributes"][key] = str(value) # Keycloak expects string values 183 | 184 | if self.debug: 185 | self.log('DEBUG', f'Realm data: {json.dumps(realm_data, indent=2)}') 186 | 187 | response = self.session.post( 188 | f"{self.admin_base_url}/realms", 189 | json=realm_data 190 | ) 191 | 192 | if response.status_code != 201: 193 | self.log('ERROR', f'Failed to create realm. Status: {response.status_code}') 194 | self.log('ERROR', f'Response: {response.text}') 195 | return False 196 | 197 | self.log('SUCCESS', f'Realm {realm_name} created') 198 | 199 | # Log SPIFFE attributes if they were set 200 | if 'attributes' in realm_config: 201 | self.log('INFO', 'Realm attributes configured:') 202 | for key, value in realm_config['attributes'].items(): 203 | self.log('INFO', f' {key}: {value}') 204 | 205 | return True 206 | 207 | except requests.exceptions.RequestException as e: 208 | self.log('ERROR', f'Failed to create realm {realm_name}: {e}') 209 | if hasattr(e, 'response') and e.response is not None: 210 | try: 211 | error_detail = e.response.json() 212 | self.log('ERROR', f'Error details: {error_detail}') 213 | except: 214 | self.log('ERROR', f'Error response: {e.response.text}') 215 | return False 216 | 217 | def update_realm_attributes(self, realm_name: str, realm_config: Dict[str, Any]) -> bool: 218 | """Update realm attributes for an existing realm.""" 219 | if 'attributes' not in realm_config: 220 | self.log('INFO', 'No attributes to update') 221 | return True 222 | 223 | self.log('INFO', f'Updating attributes for realm: {realm_name}...') 224 | 225 | try: 226 | # Get current realm configuration 227 | response = self.session.get(f"{self.admin_base_url}/realms/{realm_name}") 228 | response.raise_for_status() 229 | current_realm = response.json() 230 | 231 | # Get current attributes or initialize empty dict 232 | current_attributes = current_realm.get('attributes', {}) 233 | 234 | # Add/update new attributes 235 | for key, value in realm_config['attributes'].items(): 236 | current_attributes[key] = str(value) # Keycloak expects string values 237 | 238 | # Update the current realm configuration with new attributes 239 | current_realm["attributes"] = current_attributes 240 | 241 | if self.debug: 242 | self.log('DEBUG', f'Updating realm with attributes: {json.dumps({"attributes": current_attributes}, indent=2)}') 243 | 244 | # Update the realm with complete configuration 245 | response = self.session.put( 246 | f"{self.admin_base_url}/realms/{realm_name}", 247 | json=current_realm 248 | ) 249 | 250 | if response.status_code not in (200, 204): 251 | self.log('ERROR', f'Failed to update realm attributes. Status: {response.status_code}') 252 | self.log('ERROR', f'Response: {response.text}') 253 | return False 254 | 255 | self.log('SUCCESS', f'Realm {realm_name} attributes updated') 256 | 257 | # Log updated attributes 258 | self.log('INFO', 'Updated realm attributes:') 259 | for key, value in realm_config['attributes'].items(): 260 | self.log('INFO', f' {key}: {value}') 261 | 262 | return True 263 | 264 | except requests.exceptions.RequestException as e: 265 | self.log('ERROR', f'Failed to update realm attributes for {realm_name}: {e}') 266 | if hasattr(e, 'response') and e.response is not None: 267 | try: 268 | error_detail = e.response.json() 269 | self.log('ERROR', f'Error details: {error_detail}') 270 | except: 271 | self.log('ERROR', f'Error response: {e.response.text}') 272 | return False 273 | 274 | def create_client_scope(self, realm_name: str, scope_config: Dict[str, Any]) -> bool: 275 | """Create a client scope.""" 276 | scope_name = scope_config['name'] 277 | self.log('INFO', f'Creating client scope: {scope_name}...') 278 | 279 | try: 280 | # Check if scope exists 281 | response = self.session.get(f"{self.admin_base_url}/realms/{realm_name}/client-scopes") 282 | response.raise_for_status() 283 | 284 | existing_scopes = response.json() 285 | if any(scope['name'] == scope_name for scope in existing_scopes): 286 | self.log('WARNING', f'Client scope {scope_name} already exists, skipping creation') 287 | return True 288 | 289 | # Create scope 290 | response = self.session.post( 291 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes", 292 | json=scope_config 293 | ) 294 | response.raise_for_status() 295 | 296 | self.log('SUCCESS', f'Client scope {scope_name} created') 297 | 298 | # Add mappers if specified 299 | if 'mappers' in scope_config and scope_config['mappers']: 300 | scope_id = self.get_client_scope_id(realm_name, scope_name) 301 | if scope_id: 302 | for mapper in scope_config['mappers']: 303 | self.add_scope_mapper(realm_name, scope_id, mapper) 304 | 305 | # Add role scope mappings if 'roles' is specified 306 | if 'roles' in scope_config and scope_config['roles']: 307 | scope_id = self.get_client_scope_id(realm_name, scope_name) 308 | if scope_id: 309 | for role_assoc in scope_config['roles']: 310 | client = role_assoc['client'] 311 | role = role_assoc['role'] 312 | # Get client UUID 313 | client_uuid = self.get_client_uuid(realm_name, client) 314 | if not client_uuid: 315 | self.log('ERROR', f'Client {client} not found for scope mapping') 316 | continue 317 | # Get role details 318 | try: 319 | response = self.session.get( 320 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/roles/{role}" 321 | ) 322 | response.raise_for_status() 323 | role_data = response.json() 324 | except requests.exceptions.RequestException as e: 325 | self.log('ERROR', f'Failed to get role {role} for client {client}: {e}') 326 | continue 327 | # Add scope mapping 328 | try: 329 | response = self.session.post( 330 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/scope-mappings/clients/{client_uuid}", 331 | json=[role_data] 332 | ) 333 | if response.status_code not in (204, 201): 334 | self.log('ERROR', f'Failed to add scope mapping for role {role} to scope {scope_name}: {response.text}') 335 | else: 336 | self.log('SUCCESS', f'Added scope mapping: {role} to client scope {scope_name}') 337 | except requests.exceptions.RequestException as e: 338 | self.log('ERROR', f'Failed to add scope mapping for role {role} to scope {scope_name}: {e}') 339 | 340 | return True 341 | 342 | except requests.exceptions.RequestException as e: 343 | self.log('ERROR', f'Failed to create client scope {scope_name}: {e}') 344 | return False 345 | 346 | def get_client_scope_id(self, realm_name: str, scope_name: str) -> Optional[str]: 347 | """Get client scope UUID by name.""" 348 | try: 349 | response = self.session.get(f"{self.admin_base_url}/realms/{realm_name}/client-scopes") 350 | response.raise_for_status() 351 | 352 | scopes = response.json() 353 | for scope in scopes: 354 | if scope['name'] == scope_name: 355 | return scope['id'] 356 | 357 | return None 358 | 359 | except requests.exceptions.RequestException as e: 360 | self.log('ERROR', f'Failed to get client scope ID for {scope_name}: {e}') 361 | return None 362 | 363 | def add_scope_mapper(self, realm_name: str, scope_id: str, mapper_config: Dict[str, Any]) -> bool: 364 | """Add a mapper to a client scope.""" 365 | mapper_name = mapper_config['name'] 366 | self.log('INFO', f'Adding mapper {mapper_name} to scope...') 367 | 368 | try: 369 | # Check if mapper exists 370 | response = self.session.get( 371 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/protocol-mappers/models" 372 | ) 373 | response.raise_for_status() 374 | 375 | existing_mappers = response.json() 376 | if any(mapper['name'] == mapper_name for mapper in existing_mappers): 377 | self.log('WARNING', f'Mapper {mapper_name} already exists, skipping creation') 378 | return True 379 | 380 | # Create mapper 381 | mapper_data = { 382 | "name": mapper_name, 383 | "protocol": "openid-connect", 384 | "protocolMapper": mapper_config['type'], 385 | "config": mapper_config['config'] 386 | } 387 | 388 | response = self.session.post( 389 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/protocol-mappers/models", 390 | json=mapper_data 391 | ) 392 | response.raise_for_status() 393 | 394 | self.log('SUCCESS', f'Mapper {mapper_name} added') 395 | return True 396 | 397 | except requests.exceptions.RequestException as e: 398 | self.log('ERROR', f'Failed to add mapper {mapper_name}: {e}') 399 | return False 400 | 401 | def create_client(self, realm_name: str, client_config: Dict[str, Any]) -> bool: 402 | """Create a client.""" 403 | client_id = client_config['clientId'] 404 | self.log('INFO', f'Creating client: {client_id}...') 405 | 406 | try: 407 | # Check if client exists 408 | response = self.session.get( 409 | f"{self.admin_base_url}/realms/{realm_name}/clients", 410 | params={"clientId": client_id} 411 | ) 412 | response.raise_for_status() 413 | 414 | existing_clients = response.json() 415 | if existing_clients: 416 | self.log('WARNING', f'Client {client_id} already exists, updating token exchange settings...') 417 | # Update existing client with token exchange settings 418 | client_uuid = existing_clients[0]['id'] 419 | return self.update_client_token_exchange(realm_name, client_uuid, client_config) 420 | 421 | # Prepare client configuration 422 | client_data = { 423 | "clientId": client_id, 424 | "name": client_config.get('name', client_id), 425 | "enabled": client_config.get('enabled', True), 426 | "protocol": "openid-connect" 427 | } 428 | 429 | # Configure based on client type 430 | if client_config['type'] == 'public': 431 | client_data.update({ 432 | "publicClient": True, 433 | "standardFlowEnabled": client_config.get('standardFlowEnabled', True), 434 | "directAccessGrantsEnabled": client_config.get('directAccessGrantsEnabled', True), 435 | "redirectUris": client_config.get('redirectUris', []), 436 | "webOrigins": client_config.get('webOrigins', []) 437 | }) 438 | else: # confidential 439 | client_data.update({ 440 | "publicClient": False, 441 | "serviceAccountsEnabled": client_config.get('serviceAccountsEnabled', True), 442 | "standardFlowEnabled": client_config.get('standardFlowEnabled', False), 443 | "directAccessGrantsEnabled": client_config.get('directAccessGrantsEnabled', False) 444 | }) 445 | 446 | # Add redirectUris and webOrigins for both client types if specified 447 | if 'redirectUris' in client_config: 448 | client_data["redirectUris"] = client_config['redirectUris'] 449 | if 'webOrigins' in client_config: 450 | client_data["webOrigins"] = client_config['webOrigins'] 451 | 452 | # Set fullScopeAllowed from config (defaults to true if not specified) 453 | if 'fullScopeAllowed' in client_config: 454 | client_data["fullScopeAllowed"] = client_config['fullScopeAllowed'] 455 | 456 | # Add client secret if provided 457 | if client_config.get('clientSecret'): 458 | client_data["secret"] = client_config['clientSecret'] 459 | 460 | # Set client authenticator type (defaults to "client-secret" if not specified) 461 | client_data["clientAuthenticatorType"] = client_config.get('clientAuthenticatorType', 'client-secret') 462 | 463 | # Initialize attributes if not already present 464 | if "attributes" not in client_data: 465 | client_data["attributes"] = {} 466 | 467 | # Add custom attributes from config 468 | if 'attributes' in client_config: 469 | for key, value in client_config['attributes'].items(): 470 | client_data["attributes"][key] = str(value) # Keycloak expects string values 471 | 472 | # Add token exchange settings for confidential clients 473 | if client_config.get('tokenExchange', {}).get('enabled', False): 474 | client_data["attributes"]["token.exchange.standard.enabled"] = "true" 475 | 476 | refresh_setting = client_config.get('tokenExchange', {}).get('allowRefreshToken') 477 | if refresh_setting: 478 | client_data["attributes"]["token.exchange.refresh.enabled"] = refresh_setting 479 | 480 | if self.debug: 481 | self.log('DEBUG', f'Client data: {json.dumps(client_data, indent=2)}') 482 | 483 | # Create client 484 | response = self.session.post( 485 | f"{self.admin_base_url}/realms/{realm_name}/clients", 486 | json=client_data 487 | ) 488 | response.raise_for_status() 489 | 490 | self.log('SUCCESS', f'Client {client_id} created') 491 | 492 | # Create client roles if specified 493 | if 'roles' in client_config: 494 | for role in client_config['roles']: 495 | self.create_client_role(realm_name, client_id, role) 496 | 497 | # Add protocol mappers if specified 498 | if 'protocolMappers' in client_config: 499 | for mapper in client_config['protocolMappers']: 500 | if not self.add_client_protocol_mapper(realm_name, client_id, mapper): 501 | self.log('WARNING', f'Failed to add protocol mapper {mapper["name"]} to client {client_id}') 502 | 503 | return True 504 | 505 | except requests.exceptions.RequestException as e: 506 | self.log('ERROR', f'Failed to create client {client_id}: {e}') 507 | return False 508 | 509 | def update_client_token_exchange(self, realm_name: str, client_uuid: str, client_config: Dict[str, Any]) -> bool: 510 | """Update client with token exchange settings.""" 511 | client_id = client_config['clientId'] 512 | self.log('INFO', f'Updating token exchange settings for client {client_id}...') 513 | 514 | try: 515 | # Get current client configuration 516 | response = self.session.get(f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}") 517 | response.raise_for_status() 518 | current_client = response.json() 519 | 520 | # Initialize attributes if not already present 521 | if "attributes" not in current_client: 522 | current_client["attributes"] = {} 523 | 524 | # Add custom attributes from config 525 | if 'attributes' in client_config: 526 | for key, value in client_config['attributes'].items(): 527 | current_client["attributes"][key] = str(value) # Keycloak expects string values 528 | 529 | # Update attributes for token exchange 530 | if client_config.get('tokenExchange', {}).get('enabled', False): 531 | current_client["attributes"]["token.exchange.standard.enabled"] = "true" 532 | 533 | refresh_setting = client_config.get('tokenExchange', {}).get('allowRefreshToken') 534 | if refresh_setting: 535 | current_client["attributes"]["token.exchange.refresh.enabled"] = refresh_setting 536 | 537 | if self.debug: 538 | self.log('DEBUG', f'Updated client attributes: {current_client.get("attributes", {})}') 539 | 540 | # Update the client 541 | response = self.session.put( 542 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}", 543 | json=current_client 544 | ) 545 | response.raise_for_status() 546 | 547 | self.log('SUCCESS', f'Token exchange enabled for client {client_id}') 548 | 549 | # Add protocol mappers if specified (for existing clients) 550 | if 'protocolMappers' in client_config: 551 | for mapper in client_config['protocolMappers']: 552 | if not self.add_client_protocol_mapper(realm_name, client_id, mapper): 553 | self.log('WARNING', f'Failed to add protocol mapper {mapper["name"]} to existing client {client_id}') 554 | 555 | return True 556 | 557 | except requests.exceptions.RequestException as e: 558 | self.log('ERROR', f'Failed to update token exchange for client {client_id}: {e}') 559 | return False 560 | 561 | def get_client_uuid(self, realm_name: str, client_id: str) -> Optional[str]: 562 | """Get client UUID by client ID.""" 563 | try: 564 | response = self.session.get( 565 | f"{self.admin_base_url}/realms/{realm_name}/clients", 566 | params={"clientId": client_id} 567 | ) 568 | response.raise_for_status() 569 | 570 | clients = response.json() 571 | if clients: 572 | return clients[0]['id'] 573 | 574 | return None 575 | 576 | except requests.exceptions.RequestException as e: 577 | self.log('ERROR', f'Failed to get client UUID for {client_id}: {e}') 578 | return None 579 | 580 | def create_client_role(self, realm_name: str, client_id: str, role_config: Dict[str, Any]) -> bool: 581 | """Create a client role.""" 582 | role_name = role_config['name'] 583 | self.log('INFO', f'Creating role {role_name} in client {client_id}...') 584 | 585 | try: 586 | client_uuid = self.get_client_uuid(realm_name, client_id) 587 | if not client_uuid: 588 | self.log('ERROR', f'Client {client_id} not found') 589 | return False 590 | 591 | # Check if role exists 592 | response = self.session.get( 593 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/roles/{role_name}" 594 | ) 595 | if response.status_code == 200: 596 | self.log('WARNING', f'Role {role_name} already exists in client {client_id}, skipping creation') 597 | return True 598 | 599 | # Create role 600 | role_data = { 601 | "name": role_name, 602 | "description": role_config.get('description', '') 603 | } 604 | 605 | response = self.session.post( 606 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/roles", 607 | json=role_data 608 | ) 609 | response.raise_for_status() 610 | 611 | self.log('SUCCESS', f'Role {role_name} created in client {client_id}') 612 | return True 613 | 614 | except requests.exceptions.RequestException as e: 615 | self.log('ERROR', f'Failed to create role {role_name} in client {client_id}: {e}') 616 | return False 617 | 618 | def create_realm_role(self, realm_name: str, role_config: Dict[str, Any]) -> bool: 619 | """Create a realm role.""" 620 | role_name = role_config['name'] 621 | self.log('INFO', f'Creating realm role: {role_name}...') 622 | 623 | try: 624 | # Check if role exists 625 | if self.get_realm_role(realm_name, role_name): 626 | self.log('WARNING', f'Realm role {role_name} already exists, skipping creation') 627 | return True 628 | 629 | # Create role with proper payload structure 630 | role_data = { 631 | "name": role_name, 632 | "description": role_config.get('description', ''), 633 | "composite": role_config.get('composite', False), 634 | "clientRole": False, # Always false for realm roles 635 | "attributes": role_config.get('attributes', {}) 636 | } 637 | 638 | if self.debug: 639 | self.log('DEBUG', f'Realm role data: {json.dumps(role_data, indent=2)}') 640 | 641 | response = self.session.post( 642 | f"{self.admin_base_url}/realms/{realm_name}/roles", 643 | json=role_data 644 | ) 645 | 646 | if response.status_code == 201: 647 | self.log('SUCCESS', f'Realm role {role_name} created') 648 | return True 649 | elif response.status_code == 409: 650 | self.log('WARNING', f'Realm role {role_name} already exists, skipping creation') 651 | return True 652 | else: 653 | self.log('ERROR', f'Failed to create realm role. Status: {response.status_code}') 654 | self.log('ERROR', f'Response: {response.text}') 655 | return False 656 | 657 | except requests.exceptions.RequestException as e: 658 | self.log('ERROR', f'Failed to create realm role {role_name}: {e}') 659 | if hasattr(e, 'response') and e.response is not None: 660 | try: 661 | error_detail = e.response.json() 662 | self.log('ERROR', f'Error details: {error_detail}') 663 | except: 664 | self.log('ERROR', f'Error response: {e.response.text}') 665 | return False 666 | 667 | def get_realm_role(self, realm_name: str, role_name: str) -> Optional[Dict[str, Any]]: 668 | """Get realm role details by name.""" 669 | try: 670 | response = self.session.get( 671 | f"{self.admin_base_url}/realms/{realm_name}/roles/{role_name}" 672 | ) 673 | if response.status_code == 200: 674 | return response.json() 675 | elif response.status_code == 404: 676 | return None 677 | else: 678 | self.log('ERROR', f'Failed to get realm role {role_name}. Status: {response.status_code}') 679 | return None 680 | 681 | except requests.exceptions.RequestException as e: 682 | self.log('ERROR', f'Failed to get realm role {role_name}: {e}') 683 | return None 684 | 685 | def assign_realm_role_to_user(self, realm_name: str, username: str, role_name: str) -> bool: 686 | """Assign a realm role to a user.""" 687 | self.log('INFO', f'Assigning realm role {role_name} to user {username}...') 688 | 689 | try: 690 | user_uuid = self.get_user_uuid(realm_name, username) 691 | role_data = self.get_realm_role(realm_name, role_name) 692 | 693 | if not user_uuid: 694 | self.log('ERROR', f'User {username} not found') 695 | return False 696 | 697 | if not role_data: 698 | self.log('ERROR', f'Realm role {role_name} not found') 699 | return False 700 | 701 | # Check if role is already assigned 702 | response = self.session.get( 703 | f"{self.admin_base_url}/realms/{realm_name}/users/{user_uuid}/role-mappings/realm" 704 | ) 705 | response.raise_for_status() 706 | 707 | assigned_roles = response.json() 708 | if any(role['name'] == role_name for role in assigned_roles): 709 | self.log('WARNING', f'Realm role {role_name} already assigned to user {username}, skipping') 710 | return True 711 | 712 | # Assign role 713 | response = self.session.post( 714 | f"{self.admin_base_url}/realms/{realm_name}/users/{user_uuid}/role-mappings/realm", 715 | json=[role_data] 716 | ) 717 | response.raise_for_status() 718 | 719 | self.log('SUCCESS', f'Realm role {role_name} assigned to user {username}') 720 | return True 721 | 722 | except requests.exceptions.RequestException as e: 723 | self.log('ERROR', f'Failed to assign realm role {role_name} to user {username}: {e}') 724 | if hasattr(e, 'response') and e.response is not None: 725 | try: 726 | error_detail = e.response.json() 727 | self.log('ERROR', f'Error details: {error_detail}') 728 | except: 729 | self.log('ERROR', f'Error response: {e.response.text}') 730 | return False 731 | 732 | def add_realm_role_scope_mapping(self, realm_name: str, scope_name: str, role_name: str) -> bool: 733 | """Add a realm role scope mapping to a client scope.""" 734 | self.log('INFO', f'Adding realm role {role_name} scope mapping to scope {scope_name}...') 735 | 736 | try: 737 | scope_id = self.get_client_scope_id(realm_name, scope_name) 738 | role_data = self.get_realm_role(realm_name, role_name) 739 | 740 | if not scope_id: 741 | self.log('ERROR', f'Client scope {scope_name} not found') 742 | return False 743 | 744 | if not role_data: 745 | self.log('ERROR', f'Realm role {role_name} not found') 746 | return False 747 | 748 | # Check if realm role mapping already exists 749 | response = self.session.get( 750 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/scope-mappings/realm" 751 | ) 752 | response.raise_for_status() 753 | 754 | existing_mappings = response.json() 755 | if any(mapping['name'] == role_name for mapping in existing_mappings): 756 | self.log('WARNING', f'Realm role {role_name} already mapped to scope {scope_name}, skipping') 757 | return True 758 | 759 | # Add realm role scope mapping 760 | response = self.session.post( 761 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/scope-mappings/realm", 762 | json=[role_data] 763 | ) 764 | response.raise_for_status() 765 | 766 | self.log('SUCCESS', f'Realm role {role_name} mapped to client scope {scope_name}') 767 | return True 768 | 769 | except requests.exceptions.RequestException as e: 770 | self.log('ERROR', f'Failed to add realm role {role_name} scope mapping to scope {scope_name}: {e}') 771 | if hasattr(e, 'response') and e.response is not None: 772 | try: 773 | error_detail = e.response.json() 774 | self.log('ERROR', f'Error details: {error_detail}') 775 | except: 776 | self.log('ERROR', f'Error response: {e.response.text}') 777 | return False 778 | 779 | def add_client_protocol_mapper(self, realm_name: str, client_id: str, mapper_config: Dict[str, Any]) -> bool: 780 | """Add a protocol mapper to a client.""" 781 | mapper_name = mapper_config['name'] 782 | self.log('INFO', f'Adding protocol mapper {mapper_name} to client {client_id}...') 783 | 784 | try: 785 | client_uuid = self.get_client_uuid(realm_name, client_id) 786 | if not client_uuid: 787 | self.log('ERROR', f'Client {client_id} not found') 788 | return False 789 | 790 | # Check if mapper exists 791 | response = self.session.get( 792 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/protocol-mappers/models" 793 | ) 794 | response.raise_for_status() 795 | 796 | existing_mappers = response.json() 797 | if any(mapper['name'] == mapper_name for mapper in existing_mappers): 798 | self.log('WARNING', f'Protocol mapper {mapper_name} already exists in client {client_id}, skipping creation') 799 | return True 800 | 801 | # Create mapper 802 | mapper_data = { 803 | "name": mapper_name, 804 | "protocol": mapper_config.get('protocol', 'openid-connect'), 805 | "protocolMapper": mapper_config['type'], 806 | "config": mapper_config['config'] 807 | } 808 | 809 | response = self.session.post( 810 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/protocol-mappers/models", 811 | json=mapper_data 812 | ) 813 | response.raise_for_status() 814 | 815 | self.log('SUCCESS', f'Protocol mapper {mapper_name} added to client {client_id}') 816 | return True 817 | 818 | except requests.exceptions.RequestException as e: 819 | self.log('ERROR', f'Failed to add protocol mapper {mapper_name} to client {client_id}: {e}') 820 | return False 821 | 822 | def assign_client_scope(self, realm_name: str, client_id: str, scope_name: str, assignment_type: str = 'default') -> bool: 823 | """Assign a client scope to a client.""" 824 | self.log('INFO', f'Assigning scope {scope_name} to client {client_id} as {assignment_type}...') 825 | 826 | try: 827 | client_uuid = self.get_client_uuid(realm_name, client_id) 828 | scope_uuid = self.get_client_scope_id(realm_name, scope_name) 829 | 830 | if not client_uuid: 831 | self.log('ERROR', f'Client {client_id} not found') 832 | return False 833 | 834 | if not scope_uuid: 835 | self.log('ERROR', f'Client scope {scope_name} not found') 836 | return False 837 | 838 | # Check if scope is already assigned 839 | response = self.session.get( 840 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/{assignment_type}-client-scopes" 841 | ) 842 | response.raise_for_status() 843 | 844 | assigned_scopes = response.json() 845 | if any(scope['name'] == scope_name for scope in assigned_scopes): 846 | self.log('WARNING', f'Scope {scope_name} already assigned to client {client_id} as {assignment_type}, skipping') 847 | return True 848 | 849 | # Assign scope 850 | response = self.session.put( 851 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/{assignment_type}-client-scopes/{scope_uuid}" 852 | ) 853 | response.raise_for_status() 854 | 855 | self.log('SUCCESS', f'Scope {scope_name} assigned to client {client_id} as {assignment_type}') 856 | return True 857 | 858 | except requests.exceptions.RequestException as e: 859 | self.log('ERROR', f'Failed to assign scope {scope_name} to client {client_id}: {e}') 860 | return False 861 | 862 | def create_user(self, realm_name: str, user_config: Dict[str, Any]) -> bool: 863 | """Create a user.""" 864 | username = user_config['username'] 865 | self.log('INFO', f'Creating user: {username}...') 866 | 867 | try: 868 | # Check if user exists 869 | response = self.session.get( 870 | f"{self.admin_base_url}/realms/{realm_name}/users", 871 | params={"username": username} 872 | ) 873 | response.raise_for_status() 874 | 875 | existing_users = response.json() 876 | if existing_users: 877 | self.log('WARNING', f'User {username} already exists, skipping creation') 878 | return True 879 | 880 | # Create user 881 | user_data = { 882 | "username": username, 883 | "email": user_config.get('email'), 884 | "firstName": user_config.get('firstName'), 885 | "lastName": user_config.get('lastName'), 886 | "enabled": user_config.get('enabled', True), 887 | "emailVerified": user_config.get('emailVerified', True) 888 | } 889 | 890 | if 'password' in user_config: 891 | user_data["credentials"] = [{ 892 | "type": "password", 893 | "value": user_config['password'], 894 | "temporary": user_config.get('temporary', False) 895 | }] 896 | 897 | response = self.session.post( 898 | f"{self.admin_base_url}/realms/{realm_name}/users", 899 | json=user_data 900 | ) 901 | response.raise_for_status() 902 | 903 | self.log('SUCCESS', f'User {username} created') 904 | 905 | # Assign client roles if specified 906 | if 'clientRoles' in user_config: 907 | for client_id, roles in user_config['clientRoles'].items(): 908 | for role_name in roles: 909 | self.assign_client_role_to_user(realm_name, username, client_id, role_name) 910 | 911 | return True 912 | 913 | except requests.exceptions.RequestException as e: 914 | self.log('ERROR', f'Failed to create user {username}: {e}') 915 | return False 916 | 917 | def get_user_uuid(self, realm_name: str, username: str) -> Optional[str]: 918 | """Get user UUID by username.""" 919 | try: 920 | response = self.session.get( 921 | f"{self.admin_base_url}/realms/{realm_name}/users", 922 | params={"username": username} 923 | ) 924 | response.raise_for_status() 925 | 926 | users = response.json() 927 | if users: 928 | return users[0]['id'] 929 | 930 | return None 931 | 932 | except requests.exceptions.RequestException as e: 933 | self.log('ERROR', f'Failed to get user UUID for {username}: {e}') 934 | return None 935 | 936 | def assign_client_role_to_user(self, realm_name: str, username: str, client_id: str, role_name: str) -> bool: 937 | """Assign a client role to a user.""" 938 | self.log('INFO', f'Assigning role {role_name} from client {client_id} to user {username}...') 939 | 940 | try: 941 | user_uuid = self.get_user_uuid(realm_name, username) 942 | client_uuid = self.get_client_uuid(realm_name, client_id) 943 | 944 | if not user_uuid: 945 | self.log('ERROR', f'User {username} not found') 946 | return False 947 | 948 | if not client_uuid: 949 | self.log('ERROR', f'Client {client_id} not found') 950 | return False 951 | 952 | # Get role details 953 | response = self.session.get( 954 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/roles/{role_name}" 955 | ) 956 | response.raise_for_status() 957 | role_data = response.json() 958 | 959 | # Assign role 960 | response = self.session.post( 961 | f"{self.admin_base_url}/realms/{realm_name}/users/{user_uuid}/role-mappings/clients/{client_uuid}", 962 | json=[role_data] 963 | ) 964 | response.raise_for_status() 965 | 966 | self.log('SUCCESS', f'Role {role_name} from client {client_id} assigned to user {username}') 967 | return True 968 | 969 | except requests.exceptions.RequestException as e: 970 | self.log('ERROR', f'Failed to assign role {role_name} from client {client_id} to user {username}: {e}') 971 | return False 972 | 973 | def copy_authentication_flow(self, realm_name: str, source_flow: str, new_flow_name: str) -> bool: 974 | """Copy an existing authentication flow.""" 975 | self.log('INFO', f'Copying authentication flow from {source_flow} to {new_flow_name}...') 976 | 977 | try: 978 | copy_url = f"{self.admin_base_url}/realms/{realm_name}/authentication/flows/{source_flow}/copy" 979 | copy_data = {"newName": new_flow_name} 980 | 981 | response = self.session.post(copy_url, json=copy_data) 982 | 983 | if response.status_code == 201: 984 | self.log('SUCCESS', f'Authentication flow {new_flow_name} created from {source_flow}') 985 | return True 986 | elif response.status_code == 409: 987 | self.log('WARNING', f'Authentication flow {new_flow_name} already exists, skipping creation') 988 | return True 989 | else: 990 | self.log('ERROR', f'Failed to copy authentication flow. Status: {response.status_code}') 991 | self.log('ERROR', f'Response: {response.text}') 992 | return False 993 | 994 | except requests.exceptions.RequestException as e: 995 | self.log('ERROR', f'Failed to copy authentication flow {new_flow_name}: {e}') 996 | return False 997 | 998 | def add_authenticator_to_flow(self, realm_name: str, flow_name: str, provider_id: str) -> bool: 999 | """Add an authenticator to an authentication flow.""" 1000 | self.log('INFO', f'Adding authenticator {provider_id} to flow {flow_name}...') 1001 | 1002 | try: 1003 | add_exec_url = f"{self.admin_base_url}/realms/{realm_name}/authentication/flows/{flow_name}/executions/execution" 1004 | exec_data = {"provider": provider_id} 1005 | 1006 | response = self.session.post(add_exec_url, json=exec_data) 1007 | 1008 | if response.status_code == 201: 1009 | self.log('SUCCESS', f'Authenticator {provider_id} added to flow {flow_name}') 1010 | return True 1011 | else: 1012 | self.log('ERROR', f'Failed to add authenticator. Status: {response.status_code}') 1013 | self.log('ERROR', f'Response: {response.text}') 1014 | return False 1015 | 1016 | except requests.exceptions.RequestException as e: 1017 | self.log('ERROR', f'Failed to add authenticator {provider_id} to flow {flow_name}: {e}') 1018 | return False 1019 | 1020 | def update_authenticator_requirement(self, realm_name: str, flow_name: str, provider_id: str, requirement: str) -> bool: 1021 | """Update the requirement level of an authenticator in a flow.""" 1022 | self.log('INFO', f'Setting authenticator {provider_id} requirement to {requirement} in flow {flow_name}...') 1023 | 1024 | try: 1025 | # Get executions 1026 | get_exec_url = f"{self.admin_base_url}/realms/{realm_name}/authentication/flows/{flow_name}/executions" 1027 | response = self.session.get(get_exec_url) 1028 | response.raise_for_status() 1029 | 1030 | executions = response.json() 1031 | 1032 | # Find the specific authenticator execution 1033 | target_execution = next((ex for ex in executions if ex.get("providerId") == provider_id), None) 1034 | 1035 | if not target_execution: 1036 | self.log('ERROR', f'Authenticator {provider_id} not found in flow {flow_name}') 1037 | return False 1038 | 1039 | # Update requirement 1040 | update_data = { 1041 | "id": target_execution["id"], 1042 | "requirement": requirement 1043 | } 1044 | 1045 | response = self.session.put(get_exec_url, json=update_data) 1046 | 1047 | if response.status_code == 204: 1048 | self.log('SUCCESS', f'Authenticator {provider_id} requirement set to {requirement}') 1049 | return True 1050 | else: 1051 | self.log('ERROR', f'Failed to update authenticator requirement. Status: {response.status_code}') 1052 | self.log('ERROR', f'Response: {response.text}') 1053 | return False 1054 | 1055 | except requests.exceptions.RequestException as e: 1056 | self.log('ERROR', f'Failed to update authenticator requirement: {e}') 1057 | return False 1058 | 1059 | def set_default_client_authentication_flow(self, realm_name: str, flow_name: str) -> bool: 1060 | """Set a flow as the default client authentication flow for the realm.""" 1061 | self.log('INFO', f'Setting {flow_name} as default client authentication flow...') 1062 | 1063 | try: 1064 | # Get current realm configuration 1065 | realm_url = f"{self.admin_base_url}/realms/{realm_name}" 1066 | response = self.session.get(realm_url) 1067 | response.raise_for_status() 1068 | 1069 | realm_config = response.json() 1070 | realm_config["clientAuthenticationFlow"] = flow_name 1071 | 1072 | # Update realm configuration 1073 | response = self.session.put(realm_url, json=realm_config) 1074 | 1075 | if response.status_code == 204: 1076 | self.log('SUCCESS', f'Default client authentication flow set to {flow_name}') 1077 | return True 1078 | else: 1079 | self.log('ERROR', f'Failed to update realm configuration. Status: {response.status_code}') 1080 | self.log('ERROR', f'Response: {response.text}') 1081 | return False 1082 | 1083 | except requests.exceptions.RequestException as e: 1084 | self.log('ERROR', f'Failed to set default client authentication flow: {e}') 1085 | return False 1086 | 1087 | def setup_authentication_flows(self, realm_name: str, flows_config: List[Dict[str, Any]]) -> bool: 1088 | """Set up authentication flows from configuration.""" 1089 | self.log('INFO', 'Setting up authentication flows...') 1090 | 1091 | for flow_config in flows_config: 1092 | flow_name = flow_config['name'] 1093 | 1094 | # Copy from existing flow if specified 1095 | if 'copyFrom' in flow_config: 1096 | if not self.copy_authentication_flow(realm_name, flow_config['copyFrom'], flow_name): 1097 | return False 1098 | 1099 | # Add authenticators 1100 | for authenticator in flow_config.get('authenticators', []): 1101 | provider_id = authenticator['providerId'] 1102 | requirement = authenticator.get('requirement', 'REQUIRED') 1103 | 1104 | # Add authenticator to flow 1105 | if not self.add_authenticator_to_flow(realm_name, flow_name, provider_id): 1106 | return False 1107 | 1108 | # Update requirement if not default 1109 | if requirement != 'REQUIRED': 1110 | if not self.update_authenticator_requirement(realm_name, flow_name, provider_id, requirement): 1111 | return False 1112 | 1113 | # Set as default flows if specified 1114 | defaults = flow_config.get('setAsDefault', {}) 1115 | if defaults.get('clientAuthentication', False): 1116 | if not self.set_default_client_authentication_flow(realm_name, flow_name): 1117 | return False 1118 | 1119 | self.log('SUCCESS', 'Authentication flows setup completed') 1120 | return True 1121 | 1122 | def get_client_secret(self, realm_name: str, client_id: str) -> Optional[str]: 1123 | """Get client secret.""" 1124 | try: 1125 | client_uuid = self.get_client_uuid(realm_name, client_id) 1126 | if not client_uuid: 1127 | return None 1128 | 1129 | response = self.session.get( 1130 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/client-secret" 1131 | ) 1132 | response.raise_for_status() 1133 | 1134 | secret_data = response.json() 1135 | return secret_data.get('value') 1136 | 1137 | except requests.exceptions.RequestException as e: 1138 | self.log('ERROR', f'Failed to get client secret for {client_id}: {e}') 1139 | return None 1140 | 1141 | def test_token_exchange(self, realm_name: str, config: Dict[str, Any]) -> bool: 1142 | """Test token exchange functionality.""" 1143 | self.log('INFO', 'Testing token exchange functionality...') 1144 | 1145 | try: 1146 | # Get user token 1147 | user_token_response = self.session.post( 1148 | f"{self.keycloak_url}/realms/{realm_name}/protocol/openid-connect/token", 1149 | data={ 1150 | "grant_type": "password", 1151 | "client_id": "user-web-app", 1152 | "username": "testuser", 1153 | "password": "password123", 1154 | "scope": "openid" 1155 | }, 1156 | headers={"Content-Type": "application/x-www-form-urlencoded"} 1157 | ) 1158 | user_token_response.raise_for_status() 1159 | user_token = user_token_response.json()['access_token'] 1160 | 1161 | # Get agent-planner client secret 1162 | planner_secret = self.get_client_secret(realm_name, "agent-planner") 1163 | if not planner_secret: 1164 | self.log('ERROR', 'Could not get agent-planner client secret') 1165 | return False 1166 | 1167 | # Test token exchange 1168 | exchange_response = self.session.post( 1169 | f"{self.keycloak_url}/realms/{realm_name}/protocol/openid-connect/token", 1170 | data={ 1171 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", 1172 | "client_id": "agent-planner", 1173 | "client_secret": planner_secret, 1174 | "subject_token": user_token, 1175 | "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", 1176 | "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", 1177 | "audience": "agent-tax-optimizer" 1178 | }, 1179 | headers={"Content-Type": "application/x-www-form-urlencoded"} 1180 | ) 1181 | exchange_response.raise_for_status() 1182 | 1183 | exchanged_token = exchange_response.json() 1184 | self.log('SUCCESS', 'Token exchange test successful!') 1185 | self.log('INFO', f'Exchanged token type: {exchanged_token.get("token_type")}') 1186 | self.log('INFO', f'Expires in: {exchanged_token.get("expires_in")} seconds') 1187 | 1188 | return True 1189 | 1190 | except requests.exceptions.RequestException as e: 1191 | self.log('ERROR', f'Token exchange test failed: {e}') 1192 | if hasattr(e, 'response') and e.response is not None: 1193 | try: 1194 | error_detail = e.response.json() 1195 | self.log('ERROR', f'Error details: {error_detail}') 1196 | except: 1197 | self.log('ERROR', f'Error response: {e.response.text}') 1198 | return False 1199 | 1200 | def setup_from_config(self, config: Dict[str, Any]) -> bool: 1201 | """Set up Keycloak from configuration.""" 1202 | self.log('INFO', 'Starting Keycloak setup from configuration...') 1203 | 1204 | # Detect admin base URL first 1205 | if not self.detect_admin_base_url(): 1206 | return False 1207 | 1208 | realm_name = config['realm']['name'] 1209 | 1210 | # Step 1: Create realm 1211 | if not self.create_realm(config['realm']): 1212 | return False 1213 | 1214 | # Step 2: Create realm roles 1215 | self.log('INFO', 'Creating realm roles...') 1216 | for role in config.get('realmRoles', []): 1217 | if not self.create_realm_role(realm_name, role): 1218 | return False 1219 | 1220 | # Step 3: Create client scopes (without role mappings) 1221 | self.log('INFO', 'Creating client scopes...') 1222 | for scope in config.get('clientScopes', []): 1223 | # Remove role mappings before creating the scope (they're handled separately) 1224 | scope_copy = dict(scope) 1225 | scope_copy.pop('roles', None) 1226 | scope_copy.pop('realmRoles', None) 1227 | if not self.create_client_scope(realm_name, scope_copy): 1228 | return False 1229 | 1230 | # Step 4: Create clients 1231 | self.log('INFO', 'Creating clients...') 1232 | for client in config.get('clients', []): 1233 | if not self.create_client(realm_name, client): 1234 | return False 1235 | 1236 | # Step 5: Assign client scopes to clients 1237 | self.log('INFO', 'Assigning client scopes to clients...') 1238 | for client in config.get('clients', []): 1239 | client_id = client['clientId'] 1240 | 1241 | # Assign default scopes 1242 | for scope_name in client.get('assignedScopes', {}).get('default', []): 1243 | if not self.assign_client_scope(realm_name, client_id, scope_name, 'default'): 1244 | self.log('WARNING', f'Failed to assign default scope {scope_name} to client {client_id}') 1245 | 1246 | # Assign optional scopes 1247 | for scope_name in client.get('assignedScopes', {}).get('optional', []): 1248 | if not self.assign_client_scope(realm_name, client_id, scope_name, 'optional'): 1249 | self.log('WARNING', f'Failed to assign optional scope {scope_name} to client {client_id}') 1250 | 1251 | # Step 6: Add role scope mappings (second pass) 1252 | self.log('INFO', 'Adding role scope mappings to client scopes...') 1253 | for scope in config.get('clientScopes', []): 1254 | scope_name = scope['name'] 1255 | 1256 | # Handle client role mappings 1257 | if 'roles' in scope and scope['roles']: 1258 | scope_id = self.get_client_scope_id(realm_name, scope_name) 1259 | if scope_id: 1260 | for role_assoc in scope['roles']: 1261 | client = role_assoc['client'] 1262 | role = role_assoc['role'] 1263 | # Get client UUID 1264 | client_uuid = self.get_client_uuid(realm_name, client) 1265 | if not client_uuid: 1266 | self.log('ERROR', f'Client {client} not found for scope mapping') 1267 | continue 1268 | # Get role details 1269 | try: 1270 | response = self.session.get( 1271 | f"{self.admin_base_url}/realms/{realm_name}/clients/{client_uuid}/roles/{role}" 1272 | ) 1273 | response.raise_for_status() 1274 | role_data = response.json() 1275 | except requests.exceptions.RequestException as e: 1276 | self.log('ERROR', f'Failed to get role {role} for client {client}: {e}') 1277 | continue 1278 | # Add scope mapping 1279 | try: 1280 | response = self.session.post( 1281 | f"{self.admin_base_url}/realms/{realm_name}/client-scopes/{scope_id}/scope-mappings/clients/{client_uuid}", 1282 | json=[role_data] 1283 | ) 1284 | if response.status_code not in (204, 201): 1285 | self.log('ERROR', f'Failed to add scope mapping for role {role} to scope {scope_name}: {response.text}') 1286 | else: 1287 | self.log('SUCCESS', f'Added scope mapping: {role} to client scope {scope_name}') 1288 | except requests.exceptions.RequestException as e: 1289 | self.log('ERROR', f'Failed to add scope mapping for role {role} to scope {scope_name}: {e}') 1290 | 1291 | # Handle realm role mappings 1292 | if 'realmRoles' in scope and scope['realmRoles']: 1293 | for role_name in scope['realmRoles']: 1294 | if not self.add_realm_role_scope_mapping(realm_name, scope_name, role_name): 1295 | self.log('WARNING', f'Failed to add realm role {role_name} scope mapping to scope {scope_name}') 1296 | 1297 | # Step 7: Create users 1298 | self.log('INFO', 'Creating users...') 1299 | for user in config.get('users', []): 1300 | if not self.create_user(realm_name, user): 1301 | return False 1302 | 1303 | # Step 8: Assign realm roles to users 1304 | self.log('INFO', 'Assigning realm roles to users...') 1305 | for user in config.get('users', []): 1306 | username = user['username'] 1307 | if 'realmRoles' in user: 1308 | for role_name in user['realmRoles']: 1309 | if not self.assign_realm_role_to_user(realm_name, username, role_name): 1310 | self.log('WARNING', f'Failed to assign realm role {role_name} to user {username}') 1311 | 1312 | # Step 9: Set up authentication flows 1313 | self.log('INFO', 'Setting up authentication flows...') 1314 | if 'authenticationFlows' in config: 1315 | if not self.setup_authentication_flows(realm_name, config['authenticationFlows']): 1316 | return False 1317 | 1318 | self.log('SUCCESS', 'Keycloak setup completed successfully!') 1319 | return True 1320 | 1321 | def print_summary(self, config: Dict[str, Any]) -> None: 1322 | """Print setup summary.""" 1323 | realm_name = config['realm']['name'] 1324 | 1325 | print(f"\n{Colors.CYAN}=== KEYCLOAK SETUP SUMMARY ==={Colors.NC}") 1326 | print(f"Keycloak URL: {self.keycloak_url}") 1327 | print(f"Realm: {realm_name}") 1328 | 1329 | # Print realm attributes if they exist 1330 | if 'attributes' in config['realm']: 1331 | print(f"\n{Colors.WHITE}Realm Attributes:{Colors.NC}") 1332 | for key, value in config['realm']['attributes'].items(): 1333 | print(f" • {key}: {value}") 1334 | 1335 | print(f"\n{Colors.WHITE}Clients:{Colors.NC}") 1336 | for client in config.get('clients', []): 1337 | client_id = client['clientId'] 1338 | client_type = client['type'] 1339 | token_exchange = client.get('tokenExchange', {}).get('enabled', False) 1340 | 1341 | print(f" • {client_id} ({client_type})") 1342 | if token_exchange: 1343 | print(f" - Token exchange: ✅ enabled") 1344 | if client['type'] == 'confidential': 1345 | secret = self.get_client_secret(realm_name, client_id) 1346 | if secret: 1347 | print(f" - Client secret: {secret}") 1348 | if 'attributes' in client: 1349 | print(f" - Custom attributes:") 1350 | for key, value in client['attributes'].items(): 1351 | print(f" • {key}: {value}") 1352 | if 'protocolMappers' in client: 1353 | print(f" - Protocol mappers:") 1354 | for mapper in client['protocolMappers']: 1355 | print(f" • {mapper['name']} ({mapper['type']})") 1356 | 1357 | print(f"\n{Colors.WHITE}Client Scopes:{Colors.NC}") 1358 | for scope in config.get('clientScopes', []): 1359 | print(f" • {scope['name']}: {scope['description']}") 1360 | if 'realmRoles' in scope and scope['realmRoles']: 1361 | print(f" - Realm role mappings: {', '.join(scope['realmRoles'])}") 1362 | if 'roles' in scope and scope['roles']: 1363 | print(f" - Client role mappings:") 1364 | for role_assoc in scope['roles']: 1365 | print(f" • {role_assoc['client']}: {role_assoc['role']}") 1366 | 1367 | print(f"\n{Colors.WHITE}Realm Roles:{Colors.NC}") 1368 | for role in config.get('realmRoles', []): 1369 | print(f" • {role['name']}: {role.get('description', 'No description')}") 1370 | 1371 | print(f"\n{Colors.WHITE}Users:{Colors.NC}") 1372 | for user in config.get('users', []): 1373 | print(f" • {user['username']} ({user['email']})") 1374 | if 'realmRoles' in user: 1375 | print(f" - Realm roles: {', '.join(user['realmRoles'])}") 1376 | if 'clientRoles' in user: 1377 | for client_id, roles in user['clientRoles'].items(): 1378 | print(f" - {client_id}: {', '.join(roles)}") 1379 | 1380 | print(f"\n{Colors.WHITE}Authentication Flows:{Colors.NC}") 1381 | for flow in config.get('authenticationFlows', []): 1382 | print(f" • {flow['name']}") 1383 | if 'copyFrom' in flow: 1384 | print(f" - Copied from: {flow['copyFrom']}") 1385 | if 'authenticators' in flow: 1386 | for auth in flow['authenticators']: 1387 | print(f" - Authenticator: {auth['providerId']} ({auth.get('requirement', 'REQUIRED')})") 1388 | if flow.get('setAsDefault', {}).get('clientAuthentication'): 1389 | print(f" - Set as default client authentication flow: ✅") 1390 | 1391 | print(f"\n{Colors.WHITE}Token Exchange Rules:{Colors.NC}") 1392 | for rule in config.get('tokenExchangeRules', []): 1393 | print(f" • {rule['description']}") 1394 | print(f" - Requester: {rule['requesterClient']}") 1395 | print(f" - Targets: {', '.join(rule['targetClients'])}") 1396 | print(f" - Scopes: {', '.join(rule['allowedScopes'])}") 1397 | 1398 | print(f"\n{Colors.GREEN}🎉 Setup complete! You can now test token exchange.{Colors.NC}") 1399 | 1400 | def print_test_commands(self, config: Dict[str, Any]) -> None: 1401 | """Print test commands.""" 1402 | realm_name = config['realm']['name'] 1403 | planner_secret = self.get_client_secret(realm_name, "agent-planner") 1404 | 1405 | print(f"\n{Colors.CYAN}=== TEST COMMANDS ==={Colors.NC}") 1406 | 1407 | print(f"\n{Colors.WHITE}1. Get user token:{Colors.NC}") 1408 | print(f"""curl -X POST \\ 1409 | {self.keycloak_url}/realms/{realm_name}/protocol/openid-connect/token \\ 1410 | -H 'Content-Type: application/x-www-form-urlencoded' \\ 1411 | -d 'grant_type=password' \\ 1412 | -d 'client_id=user-web-app' \\ 1413 | -d 'username=testuser' \\ 1414 | -d 'password=password123' \\ 1415 | -d 'scope=openid'""") 1416 | 1417 | print(f"\n{Colors.WHITE}2. Exchange token (basic):{Colors.NC}") 1418 | print(f"""curl -X POST \\ 1419 | {self.keycloak_url}/realms/{realm_name}/protocol/openid-connect/token \\ 1420 | -H 'Content-Type: application/x-www-form-urlencoded' \\ 1421 | -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \\ 1422 | -d 'client_id=agent-planner' \\ 1423 | -d 'client_secret={planner_secret}' \\ 1424 | -d 'subject_token=USER_ACCESS_TOKEN' \\ 1425 | -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \\ 1426 | -d 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \\ 1427 | -d 'audience=agent-tax-optimizer'""") 1428 | 1429 | print(f"\n{Colors.WHITE}3. Exchange token (with scope):{Colors.NC}") 1430 | print(f"""curl -X POST \\ 1431 | {self.keycloak_url}/realms/{realm_name}/protocol/openid-connect/token \\ 1432 | -H 'Content-Type: application/x-www-form-urlencoded' \\ 1433 | -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \\ 1434 | -d 'client_id=agent-planner' \\ 1435 | -d 'client_secret={planner_secret}' \\ 1436 | -d 'subject_token=USER_ACCESS_TOKEN' \\ 1437 | -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \\ 1438 | -d 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \\ 1439 | -d 'audience=agent-tax-optimizer' \\ 1440 | -d 'scope=tax:process'""") 1441 | 1442 | 1443 | def load_config(config_file: str) -> Dict[str, Any]: 1444 | """Load configuration from JSON file.""" 1445 | try: 1446 | with open(config_file, 'r') as f: 1447 | return json.load(f) 1448 | except FileNotFoundError: 1449 | print(f"{Colors.RED}[ERROR]{Colors.NC} Configuration file {config_file} not found") 1450 | sys.exit(1) 1451 | except json.JSONDecodeError as e: 1452 | print(f"{Colors.RED}[ERROR]{Colors.NC} Invalid JSON in configuration file: {e}") 1453 | sys.exit(1) 1454 | 1455 | 1456 | def main(): 1457 | """Main function.""" 1458 | parser = argparse.ArgumentParser( 1459 | description='Keycloak Token Exchange Setup Script', 1460 | formatter_class=argparse.RawDescriptionHelpFormatter, 1461 | epilog=""" 1462 | Examples: 1463 | python setup_keycloak.py --config config.json --url http://localhost:8081 1464 | python setup_keycloak.py --config config.json --url http://localhost:8081 --test 1465 | python setup_keycloak.py --config config.json --url http://localhost:8081 --admin-user admin --admin-pass mypassword 1466 | """ 1467 | ) 1468 | 1469 | parser.add_argument('--config', '-c', required=True, help='Path to configuration JSON file') 1470 | parser.add_argument('--url', '-u', default='http://localhost:8080', help='Keycloak URL (default: http://localhost:8080)') 1471 | parser.add_argument('--admin-user', default='admin', help='Admin username (default: admin)') 1472 | parser.add_argument('--admin-pass', default='admin', help='Admin password (default: admin)') 1473 | parser.add_argument('--test', action='store_true', help='Run token exchange test after setup') 1474 | parser.add_argument('--summary', action='store_true', help='Print setup summary') 1475 | parser.add_argument('--test-commands', action='store_true', help='Print test commands') 1476 | parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') 1477 | parser.add_argument('--debug', action='store_true', help='Enable debug logging') 1478 | 1479 | args = parser.parse_args() 1480 | 1481 | # Load configuration 1482 | config = load_config(args.config) 1483 | 1484 | # Initialize Keycloak setup 1485 | setup = KeycloakSetup(args.url, args.admin_user, args.admin_pass) 1486 | 1487 | # Enable debug logging if requested 1488 | if args.debug: 1489 | setup.debug = True 1490 | 1491 | # Get admin token 1492 | if not setup.get_admin_token(): 1493 | print(f"{Colors.RED}[ERROR]{Colors.NC} Failed to authenticate with Keycloak") 1494 | print(f"Check that Keycloak is running at {args.url} and credentials are correct") 1495 | sys.exit(1) 1496 | 1497 | # Run setup 1498 | if not setup.setup_from_config(config): 1499 | print(f"{Colors.RED}[ERROR]{Colors.NC} Setup failed") 1500 | sys.exit(1) 1501 | 1502 | # Print summary 1503 | if args.summary or args.verbose: 1504 | setup.print_summary(config) 1505 | 1506 | # Print test commands 1507 | if args.test_commands or args.verbose: 1508 | setup.print_test_commands(config) 1509 | 1510 | # Run test 1511 | if args.test: 1512 | print(f"\n{Colors.CYAN}=== RUNNING TOKEN EXCHANGE TEST ==={Colors.NC}") 1513 | if setup.test_token_exchange(config['realm']['name'], config): 1514 | print(f"{Colors.GREEN}✅ Token exchange test passed!{Colors.NC}") 1515 | else: 1516 | print(f"{Colors.RED}❌ Token exchange test failed!{Colors.NC}") 1517 | sys.exit(1) 1518 | 1519 | 1520 | if __name__ == '__main__': 1521 | main() --------------------------------------------------------------------------------