diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index f6f4f4e5..e4e41d73 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -21,4 +21,3 @@ jobs: # Pinned to @main intentionally: org-internal workflows propagate updates automatically. uses: kagenti/.github/.github/workflows/add-to-project.yml@main secrets: inherit - diff --git a/LOCAL_TESTING_GUIDE.md b/LOCAL_TESTING_GUIDE.md index 7316a89e..e99dde97 100644 --- a/LOCAL_TESTING_GUIDE.md +++ b/LOCAL_TESTING_GUIDE.md @@ -757,4 +757,4 @@ kubectl run test-curl --rm -i --image=curlimages/curl --restart=Never -- sh -c " jq '.[] | select(.clientId | contains(\"spiffe\")) | {clientId, clientAuthenticatorType}' " # Expected: "clientAuthenticatorType": "federated-jwt" -``` \ No newline at end of file +``` diff --git a/authbridge/authproxy/Dockerfile b/authbridge/authproxy/Dockerfile index 03377bcc..d6b0d5a1 100644 --- a/authbridge/authproxy/Dockerfile +++ b/authbridge/authproxy/Dockerfile @@ -26,4 +26,4 @@ COPY --from=builder /app/auth-proxy . EXPOSE 8080 -CMD ["./auth-proxy"] \ No newline at end of file +CMD ["./auth-proxy"] diff --git a/authbridge/authproxy/Dockerfile.init b/authbridge/authproxy/Dockerfile.init index 270499b1..c5d526d3 100644 --- a/authbridge/authproxy/Dockerfile.init +++ b/authbridge/authproxy/Dockerfile.init @@ -10,4 +10,4 @@ RUN chmod +x /usr/local/bin/init-iptables.sh USER root -ENTRYPOINT ["/usr/local/bin/init-iptables.sh"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/init-iptables.sh"] diff --git a/authbridge/authproxy/quickstart/demo-app/Dockerfile b/authbridge/authproxy/quickstart/demo-app/Dockerfile index 8a690689..612bf5a7 100644 --- a/authbridge/authproxy/quickstart/demo-app/Dockerfile +++ b/authbridge/authproxy/quickstart/demo-app/Dockerfile @@ -29,4 +29,4 @@ COPY --from=builder /app/target . EXPOSE 8081 8443 -CMD ["./target"] \ No newline at end of file +CMD ["./target"] diff --git a/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml b/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml index 297e11b1..a20b4ea6 100644 --- a/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml +++ b/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml @@ -53,4 +53,3 @@ spec: targetPort: 8443 name: https type: ClusterIP - diff --git a/authbridge/authproxy/quickstart/setup_keycloak.py b/authbridge/authproxy/quickstart/setup_keycloak.py index 9f4e707d..1d2f900d 100644 --- a/authbridge/authproxy/quickstart/setup_keycloak.py +++ b/authbridge/authproxy/quickstart/setup_keycloak.py @@ -1,53 +1,59 @@ -from keycloak import KeycloakAdmin, KeycloakGetError, KeycloakPostError +from keycloak import KeycloakAdmin, KeycloakPostError KEYCLOAK_URL = "http://keycloak.localtest.me:8080" KEYCLOAK_REALM = "kagenti" KEYCLOAK_ADMIN_USERNAME = "admin" KEYCLOAK_ADMIN_PASSWORD = "admin" + # Helper functions def get_or_create_user(keycloak_admin, username): users = keycloak_admin.get_users({"username": username}) user_id = None if users: # Filter strictly because search is fuzzy - existing_user = next((u for u in users if u['username'] == username), None) + existing_user = next((u for u in users if u["username"] == username), None) if existing_user: - user_id = existing_user['id'] + user_id = existing_user["id"] print(f"User '{username}' already exists.") if not user_id: - user_id = keycloak_admin.create_user({ - "username": username, - "enabled": True, - "email": f"{username}@test.com", - "emailVerified": True, - "firstName": username, - "lastName": username, - }, True) + user_id = keycloak_admin.create_user( + { + "username": username, + "enabled": True, + "email": f"{username}@test.com", + "emailVerified": True, + "firstName": username, + "lastName": username, + }, + True, + ) print(f"Created user '{username}'.") return user_id + def get_or_create_client(keycloak_admin, client_payload): existing_client_id = keycloak_admin.get_client_id(client_payload["clientId"]) if existing_client_id: - print(f"Client '{client_payload["clientId"]}' already exists.") + print(f"Client '{client_payload['clientId']}' already exists.") return existing_client_id client_id = keycloak_admin.create_client(client_payload) - print(f"Created client '{client_payload["clientId"]}'.") + print(f"Created client '{client_payload['clientId']}'.") return client_id + def get_or_create_client_scope(keycloak_admin, scope_payload): """ Creates a client scope if it doesn't exist, or returns the ID of the existing one. """ scope_name = scope_payload.get("name") - + # Keycloak python wrapper doesn't have a direct 'get_scope_id', so we list and filter scopes = keycloak_admin.get_client_scopes() for scope in scopes: - if scope['name'] == scope_name: + if scope["name"] == scope_name: print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope['id'] + return scope["id"] # Create new scope try: @@ -58,6 +64,7 @@ def get_or_create_client_scope(keycloak_admin, scope_payload): print(f"Could not create client scope '{scope_name}': {e}") raise + def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): """ Adds an audience protocol mapper to a client scope if it doesn't already exist. @@ -73,16 +80,17 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): "included.custom.audience": audience, "id.token.claim": "false", "access.token.claim": "true", - "userinfo.token.claim": "false" - } + "userinfo.token.claim": "false", + }, } - + try: keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) print(f"Added audience mapper '{mapper_name}' for audience '{audience}'") except Exception as e: print(f"Failed to add mapper '{mapper_name}': {e}") + # initialize keycloak admin client print(f"Connecting to Keycloak at {KEYCLOAK_URL} as {KEYCLOAK_ADMIN_USERNAME}...") keycloak_admin = KeycloakAdmin( @@ -90,7 +98,7 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): username=KEYCLOAK_ADMIN_USERNAME, password=KEYCLOAK_ADMIN_PASSWORD, realm_name=KEYCLOAK_REALM, - user_realm_name="master" + user_realm_name="master", ) # create test-user @@ -100,58 +108,65 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): print(f"Set password for '{test_user_name}'.") # Create application-caller Client -app_caller_id = get_or_create_client(keycloak_admin, { - "clientId": "application-caller", - "name": "Application Caller", - "enabled": True, - "publicClient": False, # Creates a confidential client (Client Auth) - "directAccessGrantsEnabled": True, - "standardFlowEnabled": False -}) +app_caller_id = get_or_create_client( + keycloak_admin, + { + "clientId": "application-caller", + "name": "Application Caller", + "enabled": True, + "publicClient": False, # Creates a confidential client (Client Auth) + "directAccessGrantsEnabled": True, + "standardFlowEnabled": False, + }, +) # Create authproxy Client -authproxy_id = get_or_create_client(keycloak_admin, { - "clientId": "authproxy", - "name": "Auth Proxy", - "enabled": True, - "publicClient": False, # Confidential client - "standardFlowEnabled": False, - "serviceAccountsEnabled": True, - "attributes": { - "standard.token.exchange.enabled": "true" - } -}) +authproxy_id = get_or_create_client( + keycloak_admin, + { + "clientId": "authproxy", + "name": "Auth Proxy", + "enabled": True, + "publicClient": False, # Confidential client + "standardFlowEnabled": False, + "serviceAccountsEnabled": True, + "attributes": {"standard.token.exchange.enabled": "true"}, + }, +) # Create demoapp Client (target service for token exchange) -demoapp_id = get_or_create_client(keycloak_admin, { - "clientId": "demoapp", - "name": "Demo App", - "enabled": True, - "publicClient": False, # Confidential client - "standardFlowEnabled": False, - "serviceAccountsEnabled": True, -}) +demoapp_id = get_or_create_client( + keycloak_admin, + { + "clientId": "demoapp", + "name": "Demo App", + "enabled": True, + "publicClient": False, # Confidential client + "standardFlowEnabled": False, + "serviceAccountsEnabled": True, + }, +) # Create `authproxy-aud` Client scope -authproxy_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": "authproxy-aud", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } -}) +authproxy_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": "authproxy-aud", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, +) add_audience_mapper(keycloak_admin, authproxy_scope_id, "authproxy-aud", "authproxy") # Create `demoapp-aud` Client scope -demoapp_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": "demoapp-aud", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } -}) +demoapp_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": "demoapp-aud", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, +) add_audience_mapper(keycloak_admin, demoapp_scope_id, "demoapp-aud", "demoapp") # Assign default scopes @@ -171,9 +186,9 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): print("-" * 50) try: - secret = keycloak_admin.get_client_secrets(app_caller_id)['value'] - print(f"Run the following command to set the client secret:") + secret = keycloak_admin.get_client_secrets(app_caller_id)["value"] + print("Run the following command to set the client secret:") print(f"export CLIENT_SECRET={secret}") except Exception as e: print(f"Could not retrieve secret: {e}") -print("-" * 50) \ No newline at end of file +print("-" * 50) diff --git a/authbridge/client-registration/README.md b/authbridge/client-registration/README.md index 99d6e268..55675283 100644 --- a/authbridge/client-registration/README.md +++ b/authbridge/client-registration/README.md @@ -101,18 +101,18 @@ sequenceDiagram Note over Helper,SPIRE: Pod Startup SPIRE->>Helper: Issue JWT SVID Helper->>Helper: Write to /opt/jwt_svid.token - + Reg->>Reg: Wait for SVID file Reg->>Reg: Extract SPIFFE ID from JWT - + Reg->>KC: Register client (SPIFFE ID) KC-->>Reg: Client created - + Reg->>KC: Get client secret KC-->>Reg: Secret value - + Reg->>Reg: Write to /shared/client-secret.txt - + App->>App: Read secret from shared volume App->>KC: Authenticate with credentials ``` @@ -183,7 +183,7 @@ spec: volumeMounts: - name: shared-data mountPath: /shared - + # SPIFFE Helper - obtains SVID from SPIRE - name: spiffe-helper image: ghcr.io/spiffe/spiffe-helper:nightly @@ -195,7 +195,7 @@ spec: mountPath: /spiffe-workload-api - name: svid-output mountPath: /opt - + # Client Registration - registers with Keycloak - name: client-registration image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest @@ -226,7 +226,7 @@ spec: mountPath: /shared - name: svid-output mountPath: /opt - + volumes: - name: shared-data emptyDir: {} diff --git a/authbridge/client-registration/client_registration.py b/authbridge/client-registration/client_registration.py index 6c4d8c75..97ba9f82 100644 --- a/authbridge/client-registration/client_registration.py +++ b/authbridge/client-registration/client_registration.py @@ -17,6 +17,7 @@ import os import re from typing import Any + import jwt from keycloak import KeycloakAdmin, KeycloakPostError @@ -45,7 +46,7 @@ def derive_keycloak_config_from_token_url(token_url: str) -> tuple[str | None, s Returns (None, None) if parsing fails. """ # Pattern: /realms//protocol/openid-connect/token - match = re.match(r'^(https?://[^/]+)/realms/([^/]+)/', token_url) + match = re.match(r"^(https?://[^/]+)/realms/([^/]+)/", token_url) if match: return match.group(1), match.group(2) return None, None @@ -134,9 +135,7 @@ def get_client_id() -> str: return decoded["sub"] -def get_or_create_audience_scope( - keycloak_admin: KeycloakAdmin, scope_name: str, audience: str -) -> str | None: +def get_or_create_audience_scope(keycloak_admin: KeycloakAdmin, scope_name: str, audience: str) -> str | None: """ Create a client scope with an audience mapper if it doesn't exist. Returns the scope ID, or None on failure. @@ -180,7 +179,7 @@ def get_or_create_audience_scope( keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) print(f'Added audience mapper for "{audience}" to scope "{scope_name}"') except Exception as e: - print(f'Note: Could not add audience mapper (might already exist): {e}') + print(f"Note: Could not add audience mapper (might already exist): {e}") return scope_id @@ -199,28 +198,17 @@ def add_scope_to_platform_clients( for platform_client_id in platform_client_ids: internal_id = keycloak_admin.get_client_id(platform_client_id) if not internal_id: - print( - f'Platform client "{platform_client_id}" not found in realm. ' - f'Skipping scope assignment.' - ) + print(f'Platform client "{platform_client_id}" not found in realm. Skipping scope assignment.') continue try: - keycloak_admin.add_client_default_client_scope( - internal_id, scope_id, {} - ) - print( - f'Added scope "{scope_name}" to platform client "{platform_client_id}".' - ) + keycloak_admin.add_client_default_client_scope(internal_id, scope_id, {}) + print(f'Added scope "{scope_name}" to platform client "{platform_client_id}".') except Exception as e: # 409 Conflict means it's already assigned — that's fine if "409" in str(e) or "already" in str(e).lower(): - print( - f'Scope "{scope_name}" already assigned to "{platform_client_id}".' - ) + print(f'Scope "{scope_name}" already assigned to "{platform_client_id}".') else: - print( - f'Could not add scope "{scope_name}" to "{platform_client_id}": {e}' - ) + print(f'Could not add scope "{scope_name}" to "{platform_client_id}": {e}') client_name = get_env_var("CLIENT_NAME") @@ -248,26 +236,21 @@ def add_scope_to_platform_clients( # Try explicit env var first, then fall back to derived value from TOKEN_URL KEYCLOAK_URL = get_env_var("KEYCLOAK_URL", DERIVED_KEYCLOAK_URL) KEYCLOAK_REALM = get_env_var("KEYCLOAK_REALM", DERIVED_KEYCLOAK_REALM) - KEYCLOAK_TOKEN_EXCHANGE_ENABLED = ( - get_env_var("KEYCLOAK_TOKEN_EXCHANGE_ENABLED", "true").lower() == "true" - ) - KEYCLOAK_CLIENT_REGISTRATION_ENABLED = ( - get_env_var("KEYCLOAK_CLIENT_REGISTRATION_ENABLED", "true").lower() == "true" - ) + KEYCLOAK_TOKEN_EXCHANGE_ENABLED = get_env_var("KEYCLOAK_TOKEN_EXCHANGE_ENABLED", "true").lower() == "true" + KEYCLOAK_CLIENT_REGISTRATION_ENABLED = get_env_var("KEYCLOAK_CLIENT_REGISTRATION_ENABLED", "true").lower() == "true" # CLIENT_AUTH_TYPE controls how the client authenticates to Keycloak: # - "client-secret": Traditional client_secret authentication (default) # - "federated-jwt": JWT-SVID authentication via SPIFFE identity provider CLIENT_AUTH_TYPE = get_env_var("CLIENT_AUTH_TYPE", "client-secret") except ValueError as e: - print( - f"Expected environment variable missing. Skipping client registration of {client_id}." - ) + print(f"Expected environment variable missing. Skipping client registration of {client_id}.") print(e) exit(1) if not KEYCLOAK_CLIENT_REGISTRATION_ENABLED: print( - f"Client registration (KEYCLOAK_CLIENT_REGISTRATION_ENABLED=false) disabled. Skipping registration of {client_id}." + "Client registration (KEYCLOAK_CLIENT_REGISTRATION_ENABLED=false) disabled." + f" Skipping registration of {client_id}." ) exit(0) @@ -290,29 +273,31 @@ def add_scope_to_platform_clients( "publicClient": False, # Enable client authentication # Enable token exchange for this client. # Token exchange allows this client to exchange tokens for other tokens, potentially across different clients. - # Use case: [EXPLAIN THE SPECIFIC USE CASE HERE, e.g., "Required for service-to-service authentication in microservices architecture."] - # Security considerations: Ensure only trusted clients have this capability, restrict scopes and permissions as needed, + # Use case: [EXPLAIN THE SPECIFIC USE CASE HERE, e.g., + # "Required for service-to-service authentication in microservices architecture."] + # Security considerations: Ensure only trusted clients have this capability, + # restrict scopes and permissions as needed, # and audit usage to prevent privilege escalation or unauthorized access. "attributes": { - "standard.token.exchange.enabled": str( - KEYCLOAK_TOKEN_EXCHANGE_ENABLED - ).lower(), # Enable token exchange + "standard.token.exchange.enabled": str(KEYCLOAK_TOKEN_EXCHANGE_ENABLED).lower(), # Enable token exchange }, } # Configure client authentication type if CLIENT_AUTH_TYPE == "federated-jwt": - print(f"Configuring client for JWT-SVID authentication (federated-jwt)") + print("Configuring client for JWT-SVID authentication (federated-jwt)") client_payload["clientAuthenticatorType"] = "federated-jwt" # Add federated JWT attributes for SPIFFE authentication # These tell Keycloak to validate JWT-SVIDs from the SPIFFE identity provider spiffe_idp_alias = get_env_var("SPIFFE_IDP_ALIAS", "spire-spiffe") - client_payload["attributes"].update({ - "jwt.credential.issuer": spiffe_idp_alias, - "jwt.credential.sub": client_id, # Must match JWT sub claim (SPIFFE ID) - }) + client_payload["attributes"].update( + { + "jwt.credential.issuer": spiffe_idp_alias, + "jwt.credential.sub": client_id, # Must match JWT sub claim (SPIFFE ID) + } + ) else: - print(f"Configuring client for client-secret authentication") + print("Configuring client for client-secret authentication") client_payload["clientAuthenticatorType"] = "client-secret" internal_client_id = register_client( @@ -326,7 +311,9 @@ def add_scope_to_platform_clients( except ValueError: secret_file_path = "/shared/client-secret.txt" print( - f'Writing secret for client ID: "{client_id}" (internal client ID: "{internal_client_id}") to file: "{secret_file_path}"' + f'Writing secret for client ID: "{client_id}"' + f' (internal client ID: "{internal_client_id}")' + f' to file: "{secret_file_path}"' ) write_client_secret( keycloak_admin, @@ -338,9 +325,7 @@ def add_scope_to_platform_clients( # --- Audience scope management --- # Create an audience scope for this agent and add it to platform clients # so their tokens include this agent's audience (required by AuthBridge). -AUDIENCE_SCOPE_ENABLED = ( - get_env_var("KEYCLOAK_AUDIENCE_SCOPE_ENABLED", "true").lower() == "true" -) +AUDIENCE_SCOPE_ENABLED = get_env_var("KEYCLOAK_AUDIENCE_SCOPE_ENABLED", "true").lower() == "true" if AUDIENCE_SCOPE_ENABLED: # Derive scope name from client_name (namespace/sa → agent-namespace-sa-aud) @@ -360,16 +345,14 @@ def add_scope_to_platform_clients( # Add to platform clients (e.g., the UI client) platform_clients_raw = get_env_var("PLATFORM_CLIENT_IDS", "kagenti") - platform_client_ids = [ - c.strip() for c in platform_clients_raw.split(",") if c.strip() - ] + platform_client_ids = [c.strip() for c in platform_clients_raw.split(",") if c.strip()] if platform_client_ids: print(f"Adding scope to platform clients: {platform_client_ids}") - add_scope_to_platform_clients( - keycloak_admin, scope_id, scope_name, platform_client_ids - ) + add_scope_to_platform_clients(keycloak_admin, scope_id, scope_name, platform_client_ids) else: - print(f'Warning: Could not create audience scope "{scope_name}". ' - f'Platform clients will not automatically include this agent\'s audience.') + print( + f'Warning: Could not create audience scope "{scope_name}". ' + f"Platform clients will not automatically include this agent's audience." + ) print("Client registration complete.") diff --git a/authbridge/client-registration/example_deployment.yaml b/authbridge/client-registration/example_deployment.yaml index 28a77246..c33a5f0b 100644 --- a/authbridge/client-registration/example_deployment.yaml +++ b/authbridge/client-registration/example_deployment.yaml @@ -105,4 +105,4 @@ spec: mountPath: /shared volumes: - name: shared-data - emptyDir: {} \ No newline at end of file + emptyDir: {} diff --git a/authbridge/client-registration/example_deployment_spiffe.yaml b/authbridge/client-registration/example_deployment_spiffe.yaml index c86c3937..2ea551b3 100644 --- a/authbridge/client-registration/example_deployment_spiffe.yaml +++ b/authbridge/client-registration/example_deployment_spiffe.yaml @@ -162,4 +162,4 @@ data: # Replace with your actual Keycloak public URL jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/kagenti", jwt_svid_file_name="jwt_svid.token"}] jwt_svid_file_mode = 0644 - include_federated_domains = true \ No newline at end of file + include_federated_domains = true diff --git a/authbridge/client-registration/requirements.txt b/authbridge/client-registration/requirements.txt index 1844c927..cba541da 100644 --- a/authbridge/client-registration/requirements.txt +++ b/authbridge/client-registration/requirements.txt @@ -1,2 +1,2 @@ python-keycloak==7.1.1 - pyjwt==2.12.1 \ No newline at end of file + pyjwt==2.12.1 diff --git a/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml b/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml index 7c6583fd..7cad6e6f 100644 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml +++ b/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml @@ -1,5 +1,5 @@ # AuthBridge Demo Deployment (No SPIFFE) -# +# # This is a simplified version that doesn't require SPIRE. # The caller uses a static client ID instead of SPIFFE ID. # @@ -303,10 +303,10 @@ spec: while [ ! -f /shared/client-secret.txt ] || [ ! -f /shared/client-id.txt ]; do sleep 2 done - + CLIENT_ID=$(cat /shared/client-id.txt) CLIENT_SECRET=$(cat /shared/client-secret.txt) - + echo "" echo "============================================" echo "Client registered with Keycloak!" diff --git a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml b/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml index fa5085ef..747e516d 100644 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml +++ b/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml @@ -1,5 +1,5 @@ # AuthBridge Demo Deployment (with SPIFFE) -# +# # This deployment demonstrates: # 1. Agent pod with automatic client registration using SPIFFE ID # 2. AuthProxy sidecar intercepts outgoing requests and exchanges tokens @@ -279,7 +279,7 @@ spec: sleep 2 done echo "SPIFFE credentials ready!" - + # Extract and save the client ID (SPIFFE ID) from the JWT # JWT format: header.payload.signature - we need the payload JWT_PAYLOAD=$(cat /opt/jwt_svid.token | cut -d'.' -f2) @@ -287,7 +287,7 @@ spec: CLIENT_ID=$(echo "$JWT_PAYLOAD"== | base64 -d 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin).get('sub',''))") echo "$CLIENT_ID" > /shared/client-id.txt echo "Client ID (SPIFFE ID): $CLIENT_ID" - + echo "Starting client registration..." python client_registration.py echo "Client registration complete!" @@ -352,10 +352,10 @@ spec: while [ ! -f /shared/client-secret.txt ] || [ ! -f /shared/client-id.txt ]; do sleep 2 done - + CLIENT_ID=$(cat /shared/client-id.txt) CLIENT_SECRET=$(cat /shared/client-secret.txt) - + echo "" echo "============================================" echo "Agent registered with Keycloak!" diff --git a/authbridge/demos/single-target/demo.md b/authbridge/demos/single-target/demo.md index a0125f61..4ce76b6e 100644 --- a/authbridge/demos/single-target/demo.md +++ b/authbridge/demos/single-target/demo.md @@ -69,17 +69,17 @@ flowchart TB extproc["ext-proc
(inbound: JWT validation)
(outbound HTTP: token exchange)
(outbound HTTPS: TLS passthrough)"] end end - + subgraph TargetPod["AUTH-TARGET POD
(namespace: authbridge)"] target["auth-target
:8081
validates aud: auth-target"] end end - + subgraph External["EXTERNAL SERVICES"] spire["SPIRE
(namespace: spire)
Provides SVIDs"] keycloak["KEYCLOAK
(namespace: keycloak)
kagenti realm + token exchange"] end - + spire --> spiffe spiffe --> clientreg clientreg --> keycloak @@ -87,7 +87,7 @@ flowchart TB envoy --> extproc extproc --> keycloak envoy --> target - + style AgentPod fill:#e3f2fd style TargetPod fill:#e8f5e9 style Sidecar fill:#fff3e0 @@ -197,15 +197,15 @@ sequenceDiagram Note over Agent,Target: RUNTIME PHASE Agent->>KC: Get token (client_credentials grant) KC-->>Agent: Token (aud: SPIFFE ID) - + Agent->>Envoy: HTTP request + Bearer token Note over Envoy: Intercepts outbound traffic - + Envoy->>ExtProc: Process request headers ExtProc->>KC: Token Exchange (RFC 8693) KC-->>ExtProc: New token (aud: auth-target) ExtProc-->>Envoy: Replace Authorization header - + Envoy->>Target: Request + exchanged token Target->>Target: Validate token (aud: auth-target) Target-->>Envoy: "authorized" diff --git a/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml b/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml index 3bc076ea..23628363 100644 --- a/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml +++ b/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml @@ -370,10 +370,10 @@ spec: while [ ! -f /shared/client-secret.txt ] || [ ! -f /shared/client-id.txt ]; do sleep 2 done - + CLIENT_ID=$(cat /shared/client-id.txt) CLIENT_SECRET=$(cat /shared/client-secret.txt) - + echo "" echo "============================================" echo "Client registered with Keycloak!" diff --git a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml b/authbridge/demos/single-target/k8s/authbridge-deployment.yaml index d9298692..20cb688e 100644 --- a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml +++ b/authbridge/demos/single-target/k8s/authbridge-deployment.yaml @@ -341,7 +341,7 @@ spec: sleep 2 done echo "SPIFFE credentials ready!" - + # Extract and save the client ID (SPIFFE ID) from the JWT # JWT format: header.payload.signature - we need the payload JWT_PAYLOAD=$(cat /opt/jwt_svid.token | cut -d'.' -f2) @@ -349,7 +349,7 @@ spec: CLIENT_ID=$(echo "$JWT_PAYLOAD"== | base64 -d 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin).get('sub',''))") echo "$CLIENT_ID" > /shared/client-id.txt echo "Client ID (SPIFFE ID): $CLIENT_ID" - + echo "Starting client registration..." python client_registration.py echo "Client registration complete!" @@ -414,10 +414,10 @@ spec: while [ ! -f /shared/client-secret.txt ] || [ ! -f /shared/client-id.txt ]; do sleep 2 done - + CLIENT_ID=$(cat /shared/client-id.txt) CLIENT_SECRET=$(cat /shared/client-secret.txt) - + echo "" echo "============================================" echo "Agent registered with Keycloak!" diff --git a/authbridge/demos/single-target/setup_keycloak.py b/authbridge/demos/single-target/setup_keycloak.py index d1807122..3db56379 100644 --- a/authbridge/demos/single-target/setup_keycloak.py +++ b/authbridge/demos/single-target/setup_keycloak.py @@ -40,9 +40,10 @@ If your namespace or service account differs, update AGENT_SPIFFE_ID below. """ -from keycloak import KeycloakAdmin, KeycloakPostError import sys +from keycloak import KeycloakAdmin, KeycloakPostError + KEYCLOAK_URL = "http://keycloak.localtest.me:8080" KEYCLOAK_REALM = "kagenti" KEYCLOAK_ADMIN_USERNAME = "admin" @@ -58,7 +59,7 @@ "email": "alice@example.com", "firstName": "Alice", "lastName": "Demo", - "password": "alice123" + "password": "alice123", } @@ -67,14 +68,16 @@ def get_or_create_realm(keycloak_admin, realm_name): try: realms = keycloak_admin.get_realms() for realm in realms: - if realm['realm'] == realm_name: + if realm["realm"] == realm_name: print(f"Realm '{realm_name}' already exists.") return - keycloak_admin.create_realm({ - "realm": realm_name, - "enabled": True, - "displayName": realm_name, - }) + keycloak_admin.create_realm( + { + "realm": realm_name, + "enabled": True, + "displayName": realm_name, + } + ) print(f"Created realm '{realm_name}'.") except Exception as e: print(f"Error checking/creating realm: {e}") @@ -82,7 +85,7 @@ def get_or_create_realm(keycloak_admin, realm_name): def get_or_create_client(keycloak_admin, client_payload): """Create client if doesn't exist, return internal client ID.""" - client_id = client_payload['clientId'] + client_id = client_payload["clientId"] existing_client_id = keycloak_admin.get_client_id(client_id) if existing_client_id: print(f"Client '{client_id}' already exists.") @@ -97,9 +100,9 @@ def get_or_create_client_scope(keycloak_admin, scope_payload): scope_name = scope_payload.get("name") scopes = keycloak_admin.get_client_scopes() for scope in scopes: - if scope['name'] == scope_name: + if scope["name"] == scope_name: print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope['id'] + return scope["id"] try: scope_id = keycloak_admin.create_client_scope(scope_payload) @@ -121,10 +124,10 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): "included.custom.audience": audience, "id.token.claim": "false", "access.token.claim": "true", - "userinfo.token.claim": "false" - } + "userinfo.token.claim": "false", + }, } - + try: keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) print(f"Added audience mapper '{mapper_name}' for audience '{audience}'") @@ -136,29 +139,27 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): def get_or_create_user(keycloak_admin, user_config): """Create a demo user if it doesn't exist.""" username = user_config["username"] - + # Check if user exists (get_users may be fuzzy, so filter for exact username) users = keycloak_admin.get_users({"username": username}) exact_users = [u for u in users if u.get("username") == username] if exact_users: print(f"User '{username}' already exists.") return exact_users[0]["id"] - + # Create user try: - user_id = keycloak_admin.create_user({ - "username": username, - "email": user_config["email"], - "firstName": user_config["firstName"], - "lastName": user_config["lastName"], - "enabled": True, - "emailVerified": True, - "credentials": [{ - "type": "password", - "value": user_config["password"], - "temporary": False - }] - }) + user_id = keycloak_admin.create_user( + { + "username": username, + "email": user_config["email"], + "firstName": user_config["firstName"], + "lastName": user_config["lastName"], + "enabled": True, + "emailVerified": True, + "credentials": [{"type": "password", "value": user_config["password"], "temporary": False}], + } + ) print(f"Created user '{username}' with ID: {user_id}") return user_id except KeycloakPostError as e: @@ -171,7 +172,7 @@ def main(): print("AuthBridge Demo - Keycloak Setup") print("=" * 60) print(f"\nAgent SPIFFE ID: {AGENT_SPIFFE_ID}") - + # Connect to Keycloak master realm first print(f"\nConnecting to Keycloak at {KEYCLOAK_URL}...") try: @@ -180,7 +181,7 @@ def main(): username=KEYCLOAK_ADMIN_USERNAME, password=KEYCLOAK_ADMIN_PASSWORD, realm_name="master", - user_realm_name="master" + user_realm_name="master", ) except Exception as e: print(f"Failed to connect to Keycloak: {e}") @@ -189,66 +190,67 @@ def main(): print("\nIf using port-forward, run:") print(" kubectl port-forward service/keycloak-service -n keycloak 8080:8080") sys.exit(1) - + # Create demo realm if needed print(f"\n--- Setting up realm: {KEYCLOAK_REALM} ---") get_or_create_realm(master_admin, KEYCLOAK_REALM) - + # Switch to demo realm keycloak_admin = KeycloakAdmin( server_url=KEYCLOAK_URL, username=KEYCLOAK_ADMIN_USERNAME, password=KEYCLOAK_ADMIN_PASSWORD, realm_name=KEYCLOAK_REALM, - user_realm_name="master" + user_realm_name="master", ) - + # Create auth-target client (required as token exchange audience target) print("\n--- Creating auth-target client ---") print("This client is required as the target audience for token exchange") - auth_target_id = get_or_create_client(keycloak_admin, { - "clientId": "auth-target", - "name": "Auth Target", - "enabled": True, - "publicClient": False, - "standardFlowEnabled": False, - "serviceAccountsEnabled": True, - "attributes": { - "standard.token.exchange.enabled": "true" - } - }) - + get_or_create_client( + keycloak_admin, + { + "clientId": "auth-target", + "name": "Auth Target", + "enabled": True, + "publicClient": False, + "standardFlowEnabled": False, + "serviceAccountsEnabled": True, + "attributes": {"standard.token.exchange.enabled": "true"}, + }, + ) + # Create client scopes print("\n--- Creating client scopes ---") - + # agent-spiffe-aud scope - adds Agent's SPIFFE ID to token audience (realm default) # This allows the auto-registered Agent client to exchange tokens - print(f"\nCreating scope for Agent's SPIFFE ID audience...") - agent_spiffe_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": "agent-spiffe-aud", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } - }) + print("\nCreating scope for Agent's SPIFFE ID audience...") + agent_spiffe_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": "agent-spiffe-aud", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, + ) add_audience_mapper(keycloak_admin, agent_spiffe_scope_id, "agent-spiffe-aud", AGENT_SPIFFE_ID) - + # auth-target-aud scope - added to exchanged tokens # This makes the AuthProxy's exchanged token valid for auth-target - auth_target_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": "auth-target-aud", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } - }) + auth_target_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": "auth-target-aud", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, + ) add_audience_mapper(keycloak_admin, auth_target_scope_id, "auth-target-aud", "auth-target") - + # Assign scopes print("\n--- Assigning scopes ---") - + # Add agent-spiffe-aud as realm default scope # This ensures all clients (including auto-registered Agent) get tokens with # the Agent's SPIFFE ID in the audience, allowing AuthProxy to exchange them @@ -257,7 +259,7 @@ def main(): print("Added 'agent-spiffe-aud' as realm default scope (all clients will get it).") except Exception as e: print(f"Note: Could not add 'agent-spiffe-aud' as realm default (might already exist): {e}") - + # Add auth-target-aud as realm OPTIONAL scope (not default!) # - OPTIONAL means: available to clients for explicit requests, but NOT auto-included in tokens # - This allows token exchange to request this scope without polluting the first token @@ -266,39 +268,39 @@ def main(): print("Added 'auth-target-aud' as realm OPTIONAL scope (available for token exchange, not auto-included).") except Exception as e: print(f"Note: Could not add 'auth-target-aud' as optional scope (might already exist): {e}") - + # Create demo user for demonstrating subject preservation print("\n--- Creating demo user ---") print("This user demonstrates how the subject (sub) claim is preserved during token exchange") get_or_create_user(keycloak_admin, DEMO_USER) - + # Retrieve and display info print("\n" + "=" * 60) print("SETUP COMPLETE") print("=" * 60) - + print("\n" + "=" * 60) print("NEXT STEPS") print("=" * 60) - + print("\n1. Deploy the AuthBridge demo:") print("\n # With SPIFFE (requires SPIRE)") print(" kubectl apply -f demos/single-target/k8s/authbridge-deployment.yaml") print("\n # OR without SPIFFE") print(" kubectl apply -f demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml\n") - + print("2. Wait for pods to be ready:") print("\n kubectl wait --for=condition=available --timeout=120s deployment/agent -n authbridge") print(" kubectl wait --for=condition=available --timeout=120s deployment/auth-target -n authbridge\n") - + print("3. Test from inside the agent pod:") print(f""" kubectl exec -it deployment/agent -n authbridge -c agent -- sh - + # Inside the container (credentials are auto-populated by client-registration): CLIENT_ID=$(cat /shared/client-id.txt) CLIENT_SECRET=$(cat /shared/client-secret.txt) - + # Get a token (simulating what a Caller would do) # The token will have aud: {AGENT_SPIFFE_ID} TOKEN=$(curl -sX POST \\ @@ -306,20 +308,20 @@ def main(): -d 'grant_type=client_credentials' \\ -d "client_id=$CLIENT_ID" \\ -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token') - + # Verify token audience (should be the Agent's SPIFFE ID) echo $TOKEN | cut -d'.' -f2 | tr '_-' '/+' | {{ read p; echo "${{p}}=="; }} | base64 -d | jq '{{aud, azp, scope}}' - + # Agent calls auth-target (AuthProxy will exchange token for aud: auth-target) curl -H "Authorization: Bearer $TOKEN" http://auth-target-service:8081/test # Expected: "authorized" """) - + print("4. Test with a USER TOKEN (demonstrates subject preservation):") print(f""" # Get a token for demo user 'alice' using password grant # This demonstrates how the user's identity (sub claim) is preserved during exchange - + USER_TOKEN=$(curl -sX POST \\ http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\ -d 'grant_type=password' \\ @@ -327,20 +329,21 @@ def main(): -d "client_secret=$CLIENT_SECRET" \\ -d 'username={DEMO_USER["username"]}' \\ -d 'password={DEMO_USER["password"]}' | jq -r '.access_token') - + # Check the ORIGINAL token - note the 'sub' claim contains alice's user ID # and 'preferred_username' shows 'alice' echo "=== ORIGINAL TOKEN (user: alice) ===" - echo $USER_TOKEN | cut -d'.' -f2 | tr '_-' '/+' | {{ read p; echo "${{p}}=="; }} | base64 -d | jq '{{sub, preferred_username, aud, azp}}' - + echo $USER_TOKEN | cut -d'.' -f2 | tr '_-' '/+' | \ + {{ read p; echo "${{p}}=="; }} | base64 -d | jq '{{sub, preferred_username, aud, azp}}' + # Call auth-target - token exchange preserves the subject! curl -H "Authorization: Bearer $USER_TOKEN" http://auth-target-service:8081/test # Expected: "authorized" - + # Check auth-target logs to see alice's subject in the exchanged token: kubectl logs deployment/auth-target -n authbridge | grep -A5 "JWT Debug" | tail -10 """) - + print("\n" + "-" * 60) print("HOW IT WORKS") print("-" * 60) diff --git a/authbridge/demos/webhook/setup_keycloak.py b/authbridge/demos/webhook/setup_keycloak.py index 27781790..2f2b070b 100644 --- a/authbridge/demos/webhook/setup_keycloak.py +++ b/authbridge/demos/webhook/setup_keycloak.py @@ -42,8 +42,9 @@ """ import argparse -import sys import os +import sys + from keycloak import KeycloakAdmin, KeycloakPostError # Default configuration @@ -73,7 +74,7 @@ "email": "alice@example.com", "firstName": "Alice", "lastName": "Demo", - "password": "alice123" + "password": "alice123", } @@ -87,14 +88,16 @@ def get_or_create_realm(keycloak_admin, realm_name): try: realms = keycloak_admin.get_realms() for realm in realms: - if realm['realm'] == realm_name: + if realm["realm"] == realm_name: print(f"Realm '{realm_name}' already exists.") return - keycloak_admin.create_realm({ - "realm": realm_name, - "enabled": True, - "displayName": realm_name, - }) + keycloak_admin.create_realm( + { + "realm": realm_name, + "enabled": True, + "displayName": realm_name, + } + ) print(f"Created realm '{realm_name}'.") except Exception as e: print(f"Error checking/creating realm: {e}") @@ -102,7 +105,7 @@ def get_or_create_realm(keycloak_admin, realm_name): def get_or_create_client(keycloak_admin, client_payload): """Create client if doesn't exist, return internal client ID.""" - client_id = client_payload['clientId'] + client_id = client_payload["clientId"] existing_client_id = keycloak_admin.get_client_id(client_id) if existing_client_id: print(f"Client '{client_id}' already exists.") @@ -117,9 +120,9 @@ def get_or_create_client_scope(keycloak_admin, scope_payload): scope_name = scope_payload.get("name") scopes = keycloak_admin.get_client_scopes() for scope in scopes: - if scope['name'] == scope_name: + if scope["name"] == scope_name: print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope['id'] + return scope["id"] try: scope_id = keycloak_admin.create_client_scope(scope_payload) @@ -141,10 +144,10 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): "included.custom.audience": audience, "id.token.claim": "false", "access.token.claim": "true", - "userinfo.token.claim": "false" - } + "userinfo.token.claim": "false", + }, } - + try: keycloak_admin.add_mapper_to_client_scope(scope_id, mapper_payload) print(f"Added audience mapper '{mapper_name}' for audience '{audience}'") @@ -156,28 +159,26 @@ def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): def get_or_create_user(keycloak_admin, user_config): """Create a demo user if it doesn't exist.""" username = user_config["username"] - + # Check if user exists users = keycloak_admin.get_users({"username": username}) if users: print(f"User '{username}' already exists.") return users[0]["id"] - + # Create user try: - user_id = keycloak_admin.create_user({ - "username": username, - "email": user_config["email"], - "firstName": user_config["firstName"], - "lastName": user_config["lastName"], - "enabled": True, - "emailVerified": True, - "credentials": [{ - "type": "password", - "value": user_config["password"], - "temporary": False - }] - }) + user_id = keycloak_admin.create_user( + { + "username": username, + "email": user_config["email"], + "firstName": user_config["firstName"], + "lastName": user_config["lastName"], + "enabled": True, + "emailVerified": True, + "credentials": [{"type": "password", "value": user_config["password"], "temporary": False}], + } + ) print(f"Created user '{username}' with ID: {user_id}") return user_id except KeycloakPostError as e: @@ -186,18 +187,18 @@ def get_or_create_user(keycloak_admin, user_config): def main(): - parser = argparse.ArgumentParser( - description="Setup Keycloak for AuthBridge webhook deployments" - ) + parser = argparse.ArgumentParser(description="Setup Keycloak for AuthBridge webhook deployments") parser.add_argument( - "--namespace", "-n", + "--namespace", + "-n", default=DEFAULT_NAMESPACE, - help=f"Kubernetes namespace for the agent (default: {DEFAULT_NAMESPACE})" + help=f"Kubernetes namespace for the agent (default: {DEFAULT_NAMESPACE})", ) parser.add_argument( - "--service-account", "-s", + "--service-account", + "-s", default=DEFAULT_SERVICE_ACCOUNT, - help=f"Service account name for the agent (default: {DEFAULT_SERVICE_ACCOUNT})" + help=f"Service account name for the agent (default: {DEFAULT_SERVICE_ACCOUNT})", ) args = parser.parse_args() @@ -211,7 +212,7 @@ def main(): print(f"\nNamespace: {namespace}") print(f"Service Account: {service_account}") print(f"SPIFFE ID: {agent_spiffe_id}") - + # Connect to Keycloak master realm first print(f"\nConnecting to Keycloak at {KEYCLOAK_URL}...") try: @@ -220,7 +221,7 @@ def main(): username=KEYCLOAK_ADMIN_USERNAME, password=KEYCLOAK_ADMIN_PASSWORD, realm_name="master", - user_realm_name="master" + user_realm_name="master", ) except Exception as e: print(f"Failed to connect to Keycloak: {e}") @@ -229,87 +230,88 @@ def main(): print("\nIf using port-forward, run:") print(" kubectl port-forward service/keycloak-service -n keycloak 8080:8080") sys.exit(1) - + # Create demo realm if needed print(f"\n--- Setting up realm: {KEYCLOAK_REALM} ---") get_or_create_realm(master_admin, KEYCLOAK_REALM) - + # Switch to demo realm keycloak_admin = KeycloakAdmin( server_url=KEYCLOAK_URL, username=KEYCLOAK_ADMIN_USERNAME, password=KEYCLOAK_ADMIN_PASSWORD, realm_name=KEYCLOAK_REALM, - user_realm_name="master" + user_realm_name="master", ) - + # Create auth-target client (required as token exchange audience target) print("\n--- Creating auth-target client ---") print("This client is required as the target audience for token exchange") - get_or_create_client(keycloak_admin, { - "clientId": "auth-target", - "name": "Auth Target", - "enabled": True, - "publicClient": False, - "standardFlowEnabled": False, - "serviceAccountsEnabled": True, - "attributes": { - "standard.token.exchange.enabled": "true" - } - }) - + get_or_create_client( + keycloak_admin, + { + "clientId": "auth-target", + "name": "Auth Target", + "enabled": True, + "publicClient": False, + "standardFlowEnabled": False, + "serviceAccountsEnabled": True, + "attributes": {"standard.token.exchange.enabled": "true"}, + }, + ) + # Create client scopes print("\n--- Creating client scopes ---") - + # agent-spiffe-aud scope - adds Agent's SPIFFE ID to token audience (realm default) scope_name = f"agent-{namespace}-{service_account}-aud" print(f"\nCreating scope for Agent's SPIFFE ID audience: {scope_name}") - agent_spiffe_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": scope_name, - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } - }) + agent_spiffe_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": scope_name, + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, + ) add_audience_mapper(keycloak_admin, agent_spiffe_scope_id, scope_name, agent_spiffe_id) - + # auth-target-aud scope - added to exchanged tokens - auth_target_scope_id = get_or_create_client_scope(keycloak_admin, { - "name": "auth-target-aud", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true", - "display.on.consent.screen": "true" - } - }) + auth_target_scope_id = get_or_create_client_scope( + keycloak_admin, + { + "name": "auth-target-aud", + "protocol": "openid-connect", + "attributes": {"include.in.token.scope": "true", "display.on.consent.screen": "true"}, + }, + ) add_audience_mapper(keycloak_admin, auth_target_scope_id, "auth-target-aud", "auth-target") - + # Assign scopes print("\n--- Assigning scopes ---") - + try: keycloak_admin.add_default_default_client_scope(agent_spiffe_scope_id) print(f"Added '{scope_name}' as realm default scope.") except Exception as e: print(f"Note: Could not add '{scope_name}' as realm default (might already exist): {e}") - + try: keycloak_admin.add_default_optional_client_scope(auth_target_scope_id) print("Added 'auth-target-aud' as realm OPTIONAL scope.") except Exception as e: print(f"Note: Could not add 'auth-target-aud' as optional scope (might already exist): {e}") - + # Create demo user print("\n--- Creating demo user ---") print("This user demonstrates subject preservation during token exchange") get_or_create_user(keycloak_admin, DEMO_USER) - + # Print summary and next steps print("\n" + "=" * 70) print("SETUP COMPLETE") print("=" * 70) - + print("\n" + "=" * 70) print("REQUIRED CONFIGMAPS") print("=" * 70) @@ -431,7 +433,8 @@ def main(): -d 'password={DEMO_USER["password"]}' | jq -r '.access_token') # Check alice's subject is preserved -echo $USER_TOKEN | cut -d'.' -f2 | tr '_-' '/+' | {{ read p; echo "${{p}}=="; }} | base64 -d | jq '{{sub, preferred_username, aud}}' +echo $USER_TOKEN | cut -d'.' -f2 | tr '_-' '/+' | \ + {{ read p; echo "${{p}}=="; }} | base64 -d | jq '{{sub, preferred_username, aud}}' """) diff --git a/authbridge/keycloak_sync.py b/authbridge/keycloak_sync.py index 4afc4949..e9dee910 100644 --- a/authbridge/keycloak_sync.py +++ b/authbridge/keycloak_sync.py @@ -10,16 +10,17 @@ import argparse import sys -import yaml from dataclasses import dataclass from typing import Optional +import yaml from keycloak import KeycloakAdmin @dataclass class RouteTarget: """A target from routes.yaml that needs reconciliation.""" + host: str audience: str scopes: list[str] @@ -29,6 +30,7 @@ class RouteTarget: @dataclass class ReconcileResult: """Summary of reconciliation actions.""" + targets_checked: int = 0 clients_created: int = 0 clients_skipped: int = 0 @@ -46,8 +48,13 @@ class KeycloakReconciler: HOSTNAME_ATTRIBUTE = "authbridge.hostname" - def __init__(self, keycloak_admin: KeycloakAdmin, dry_run: bool = False, - auto_yes: bool = False, agent_client: Optional[str] = None): + def __init__( + self, + keycloak_admin: KeycloakAdmin, + dry_run: bool = False, + auto_yes: bool = False, + agent_client: Optional[str] = None, + ): self.kc = keycloak_admin self.dry_run = dry_run self.auto_yes = auto_yes @@ -102,7 +109,7 @@ def _check_client(self, audience: str) -> Optional[str]: client_id = self.kc.get_client_id(audience) if client_id: - print(f" [OK] Client exists") + print(" [OK] Client exists") return client_id print(f" [MISSING] Client '{audience}' not found in Keycloak") @@ -152,7 +159,7 @@ def _check_scopes(self, audience: str, scopes: list[str], _client_id: str): if self._prompt(f" Add audience mapper for '{audience}'?"): self._add_audience_mapper(scope["id"], scope_name, audience) else: - print(f" [OK] Audience mapper correctly configured") + print(" [OK] Audience mapper correctly configured") # Assign scope to agent client if specified if self.agent_client_uuid: @@ -165,7 +172,7 @@ def _check_hostname(self, _audience: str, expected_host: str, client_id: str): current_host = attributes.get(self.HOSTNAME_ATTRIBUTE) if current_host is None: - print(f" [WARN] No hostname attribute set") + print(" [WARN] No hostname attribute set") if self._prompt(f" Set hostname to '{expected_host}'?"): self._set_hostname_attribute(client_id, expected_host) self.result.hostnames_set += 1 @@ -181,7 +188,7 @@ def _check_hostname(self, _audience: str, expected_host: str, client_id: str): print(" --> Skipped") self.result.hostnames_skipped += 1 else: - print(f" [OK] Hostname attribute matches") + print(" [OK] Hostname attribute matches") # --- Keycloak operations --- @@ -287,7 +294,7 @@ def _create_scope_with_mapper(self, scope_name: str, audience: str) -> bool: def _add_audience_mapper(self, scope_id: str, mapper_name: str, audience: str): """Add an audience mapper to a scope.""" if self.dry_run: - print(f" --> [DRY RUN] Would add audience mapper") + print(" --> [DRY RUN] Would add audience mapper") return mapper_payload = { @@ -304,7 +311,7 @@ def _add_audience_mapper(self, scope_id: str, mapper_name: str, audience: str): } try: self.kc.add_mapper_to_client_scope(scope_id, mapper_payload) - print(f" --> Added audience mapper") + print(" --> Added audience mapper") except Exception as e: print(f" --> Error adding mapper: {e}") @@ -319,7 +326,7 @@ def _set_hostname_attribute(self, client_id: str, hostname: str): attributes = client.get("attributes", {}) attributes[self.HOSTNAME_ATTRIBUTE] = hostname self.kc.update_client(client_id, {"attributes": attributes}) - print(f" --> Set hostname attribute") + print(" --> Set hostname attribute") except Exception as e: print(f" --> Error setting hostname: {e}") @@ -389,12 +396,14 @@ def load_routes(path: str) -> list[RouteTarget]: continue # Skip routes without audience (e.g., passthrough-only) scopes = route.get("token_scopes", "").split() - targets.append(RouteTarget( - host=route.get("host", ""), - audience=route["target_audience"], - scopes=scopes, - passthrough=route.get("passthrough", False), - )) + targets.append( + RouteTarget( + host=route.get("host", ""), + audience=route["target_audience"], + scopes=scopes, + passthrough=route.get("passthrough", False), + ) + ) return targets @@ -405,7 +414,7 @@ def print_summary(result: ReconcileResult): print("Summary:") print(f" {result.targets_checked} targets checked") if result.agent_client_created: - print(f" Agent client created") + print(" Agent client created") if result.clients_created: print(f" {result.clients_created} clients created") if result.clients_skipped: @@ -425,48 +434,20 @@ def print_summary(result: ReconcileResult): def main(): - parser = argparse.ArgumentParser( - description="Reconcile routes.yaml with Keycloak configuration" - ) + parser = argparse.ArgumentParser(description="Reconcile routes.yaml with Keycloak configuration") parser.add_argument( - "--config", "-c", + "--config", + "-c", default="/etc/authproxy/routes.yaml", - help="Path to routes.yaml (default: /etc/authproxy/routes.yaml)" - ) - parser.add_argument( - "--keycloak-url", - default="http://keycloak.localtest.me:8080", - help="Keycloak server URL" - ) - parser.add_argument( - "--realm", - default="kagenti", - help="Keycloak realm (default: kagenti)" - ) - parser.add_argument( - "--admin-user", - default="admin", - help="Keycloak admin username" - ) - parser.add_argument( - "--admin-password", - default="admin", - help="Keycloak admin password" - ) - parser.add_argument( - "--dry-run", "-n", - action="store_true", - help="Show what would be done without making changes" - ) - parser.add_argument( - "--yes", "-y", - action="store_true", - help="Answer yes to all prompts" - ) - parser.add_argument( - "--agent-client", - help="Client ID of the agent to assign scopes to (optional)" + help="Path to routes.yaml (default: /etc/authproxy/routes.yaml)", ) + parser.add_argument("--keycloak-url", default="http://keycloak.localtest.me:8080", help="Keycloak server URL") + parser.add_argument("--realm", default="kagenti", help="Keycloak realm (default: kagenti)") + parser.add_argument("--admin-user", default="admin", help="Keycloak admin username") + parser.add_argument("--admin-password", default="admin", help="Keycloak admin password") + parser.add_argument("--dry-run", "-n", action="store_true", help="Show what would be done without making changes") + parser.add_argument("--yes", "-y", action="store_true", help="Answer yes to all prompts") + parser.add_argument("--agent-client", help="Client ID of the agent to assign scopes to (optional)") args = parser.parse_args() @@ -514,11 +495,13 @@ def main(): print(f" --> [DRY RUN] Would create realm '{args.realm}'") else: try: - master_kc.create_realm({ - "realm": args.realm, - "enabled": True, - "displayName": args.realm, - }) + master_kc.create_realm( + { + "realm": args.realm, + "enabled": True, + "displayName": args.realm, + } + ) print(f" --> Created realm '{args.realm}'") except Exception as e: print(f" --> Error creating realm: {e}") @@ -547,12 +530,7 @@ def main(): if args.agent_client: print(f"Agent client: {args.agent_client}") - reconciler = KeycloakReconciler( - kc, - dry_run=args.dry_run, - auto_yes=args.yes, - agent_client=args.agent_client - ) + reconciler = KeycloakReconciler(kc, dry_run=args.dry_run, auto_yes=args.yes, agent_client=args.agent_client) result = reconciler.reconcile(targets) print_summary(result)