From f864060cde05786f72483dbcffa216ada0be242e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 16:32:41 -0400 Subject: [PATCH 1/9] fix(weather-demo): drop dead client-registration-inject label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advanced weather demo's two pod templates carried `kagenti.io/client-registration-inject: "true"`, which after PR #411 silently disables registration: * The operator's ClientRegistrationReconciler treats this label as a signal to skip — `SkipReason` in kagenti-operator/internal/clientreg/names.go:58 returns "label is true (legacy webhook client-registration sidecar; operator-managed registration disabled for this workload)". * The legacy in-pod kagenti-client-registration sidecar that the label was supposed to defer to was removed in #411. Net effect: setting this label today produces a workload with NO Keycloak client registered at all, and `setup_keycloak_weather_advanced.py --wait-tool-client` correctly times out waiting for a SPIFFE-shaped client (`spiffe://localtest.me/ns//sa/`) that nobody will ever create. This was hiding behind the kagenti repo's MLflow e2e failures upstream — once those were fixed, the AuthBridge weather e2e exposed the latent bug. Drop the label from both pod templates: * k8s/weather-tool-advanced.yaml * k8s/weather-service-advanced.yaml Plus refresh the two doc references that were directing readers to set the label: * demos/weather-agent/demo-ui-advanced.md — replace the bullet that recommended the label with one explaining operator-managed registration is now the only path. * demos/webhook/README.md — rewrite the "client registration" bullet to call out the label as removed and explain why setting it today silently breaks registration. Verified: `kubectl apply --dry-run=client -f` on both YAMLs renders clean. Repo grep for the label only matches the two doc references above (both now phrased as "don't set it"). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/weather-agent/demo-ui-advanced.md | 14 +++++++++----- .../k8s/weather-service-advanced.yaml | 1 - .../weather-agent/k8s/weather-tool-advanced.yaml | 2 -- authbridge/demos/webhook/README.md | 7 ++++++- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/authbridge/demos/weather-agent/demo-ui-advanced.md b/authbridge/demos/weather-agent/demo-ui-advanced.md index 3c96ec685..cb492f9e8 100644 --- a/authbridge/demos/weather-agent/demo-ui-advanced.md +++ b/authbridge/demos/weather-agent/demo-ui-advanced.md @@ -54,11 +54,15 @@ On current platforms, **three things** are required together for the full demo: `kagenti.io/type: agent` (the image is still `weather_tool`; only the API classification changes). -3. **`kagenti.io/client-registration-inject: "true"` label** (not annotation) on - the **pod template** so the **legacy** `kagenti-client-registration` sidecar - runs and registers the SPIFFE client in Keycloak. Without it, the operator’s - default is operator-managed registration and `setup_keycloak ... --wait-tool-client` - may never see the tool client. +3. **Operator-managed Keycloak client registration**. The operator's + `ClientRegistrationReconciler` watches `kagenti.io/type: agent` pods and + creates the SPIFFE-shaped client in Keycloak (and the corresponding + `kagenti-keycloak-client-credentials-` Secret). The legacy + `kagenti.io/client-registration-inject: "true"` label is **no longer + used** — it referenced an in-pod `kagenti-client-registration` + sidecar that was removed in #411. Setting that label today disables + operator-managed registration and leaves the workload with no + credentials at all. ## Architecture diff --git a/authbridge/demos/weather-agent/k8s/weather-service-advanced.yaml b/authbridge/demos/weather-agent/k8s/weather-service-advanced.yaml index 4da830533..c34e7d001 100644 --- a/authbridge/demos/weather-agent/k8s/weather-service-advanced.yaml +++ b/authbridge/demos/weather-agent/k8s/weather-service-advanced.yaml @@ -37,7 +37,6 @@ spec: app.kubernetes.io/name: weather-service-advanced kagenti.io/inject: enabled kagenti.io/spire: enabled - kagenti.io/client-registration-inject: "true" annotations: kagenti.io/outbound-ports-exclude: "11434" spec: diff --git a/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml b/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml index 919b0f225..83633feb4 100644 --- a/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml +++ b/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml @@ -45,8 +45,6 @@ spec: template: metadata: labels: - # SPIFFE → Keycloak client sidecar (label, not annotation — see kagenti backend constants) - kagenti.io/client-registration-inject: "true" # Required for AuthBridge + SPIRE injection (webhook does not match type=tool) kagenti.io/type: agent app.kubernetes.io/name: weather-tool-advanced diff --git a/authbridge/demos/webhook/README.md b/authbridge/demos/webhook/README.md index 2bcacf082..0bbabe47b 100644 --- a/authbridge/demos/webhook/README.md +++ b/authbridge/demos/webhook/README.md @@ -160,7 +160,12 @@ When `combinedSidecar: true`, the per-sidecar feature gates and workload labels - **`spiffeHelper: false`** or `kagenti.io/spiffe-helper-inject: "false"`: The combined container starts with `SPIRE_ENABLED=false` — spiffe-helper is not launched, and a static client ID is used instead. - **`clientRegistration: false`**: The combined container starts with `CLIENT_REGISTRATION_ENABLED=false` — client registration is skipped. -- **Default** (label not `true`): operator-managed registration; combined authbridge uses `CLIENT_REGISTRATION_ENABLED=false` unless **`kagenti.io/client-registration-inject: "true"`** opts in to the legacy registration slice. +- **Client registration**: operator-managed only. The legacy + `kagenti.io/client-registration-inject: "true"` label opted into an + in-pod `kagenti-client-registration` sidecar that was removed in + #411 — setting it today silently disables registration entirely + (the operator's `SkipReason` honors the label and steps aside, and + the legacy sidecar isn't there to take over). Don't set it. - **`envoyProxy: false`** or `kagenti.io/envoy-proxy-inject: "false"`: No combined container is injected at all (the proxy is the core component). Then continue with: From a0b6ee7264d5f9a75860f2fc6bd1c6fcf9027da5 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 16:49:32 -0400 Subject: [PATCH 2/9] chore: sweep remaining post-#411 stale references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the dead-label fix in the previous commit. Repo-wide sweep for other stale references to the pre-#411 multi-sidecar architecture, with two distinct treatments: Fixed in place (still-supported paths drifted to dead names): * local-build-and-test.sh — Dockerfile paths cmd/authbridge/Dockerfile.proxy and cmd/authbridge/Dockerfile.envoy no longer exist. Update to the post-#411 cmd/authbridge-{proxy,envoy, lite}/Dockerfile paths and add a build step for the third image (authbridge-lite). Inventory list at the end of the script expanded accordingly. * authbridge/CLAUDE.md — fix the prose at line 132 that incorrectly told future readers the kagenti.io/client-registration-inject: "true" label still works (it doesn't — operator's SkipReason honors it and the legacy sidecar that the label deferred to is gone). Fix the runtime-resources table to reflect operator-managed registration and the bundled spiffe-helper. * README.md (top-level) — image inventory had authbridge-unified + separate client-registration / spiffe-helper images. Replace with the post-#411 set: authbridge / authbridge-envoy / authbridge-lite / proxy-init, with a note explaining the bundling and that older release tags still publish the legacy shape. * authbridge/README.md — image / mode tables fully rewritten to the post-#411 set. Default mode is now proxy-sidecar (was envoy-sidecar in the old text). * authbridge/demos/webhook/README.md — overview prose, prerequisites image list, "Enabling Combined Sidecar Mode" section (the feature gate is gone), debugging-logs section, image-pull-error troubleshooting block. The `combinedSidecar`, `envoyProxy: false`, `spiffeHelper: false` per-sidecar feature gates are all retired; rewrite the section to document the post-#411 mode-resolution chain (CR → namespace ConfigMap → annotation → cluster default). * authbridge/demos/weather-agent/demo-ui.md — "Key differences" table flipped: proxy-sidecar is now the default; both rows now name the combined images instead of the dead authbridge-light/envoy split. * authbridge/demos/README.md — index page note about authbridge-unified replaced with the post-#411 image set; flag the Single-Target / Multi-Target rows as currently broken with an inline ⚠️ warning paragraph. Marked broken-needs-migration (no in-place fix without testing): * authbridge/authproxy/k8s/auth-proxy-deployment.yaml, authbridge/demos/{single,multi}-target/k8s/authbridge-deployment.yaml, authbridge/demos/{single,multi}-target/k8s/authbridge-deployment-no-spiffe.yaml use the pre-#411 multi-sidecar pattern (envoy-proxy + authbridge-light + client-registration + standalone spiffe-helper containers) with images that no longer publish. Rewriting them to the combined-sidecar shape is real work that needs each demo manually verified — out of scope for a stale-references cleanup. Add an ⚠️ broken-after-#411 header to each YAML naming the dead images so any apply attempt produces a useful explanation alongside the inevitable ImagePullBackOff. Mirror the warning at the top of demos/{single,multi}-target/demo.md. * LOCAL_TESTING_GUIDE.md (760 lines, deeply baked into the legacy shape) — top-of-file deprecation banner pointing at the working demos. The `local-build-and-test.sh` reference in §1 still works because we updated the script; the rest needs re-author. Verified: bash syntax on local-build-and-test.sh; python3 yaml parse on each touched K8s manifest; repo-wide grep for the dead strings now matches only inside warning blocks ("no longer", "BROKEN", "don't set it", etc.). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- LOCAL_TESTING_GUIDE.md | 18 +++ README.md | 24 ++-- authbridge/CLAUDE.md | 10 +- authbridge/README.md | 27 ++-- .../authproxy/k8s/auth-proxy-deployment.yaml | 7 + authbridge/demos/README.md | 22 +++- authbridge/demos/multi-target/demo.md | 13 ++ .../k8s/authbridge-deployment-no-spiffe.yaml | 4 + .../k8s/authbridge-deployment.yaml | 4 + authbridge/demos/single-target/demo.md | 9 ++ .../k8s/authbridge-deployment-no-spiffe.yaml | 5 + .../k8s/authbridge-deployment.yaml | 13 ++ authbridge/demos/weather-agent/demo-ui.md | 12 +- authbridge/demos/webhook/README.md | 121 +++++++----------- local-build-and-test.sh | 17 ++- 15 files changed, 185 insertions(+), 121 deletions(-) diff --git a/LOCAL_TESTING_GUIDE.md b/LOCAL_TESTING_GUIDE.md index e99dde97a..5cd679755 100644 --- a/LOCAL_TESTING_GUIDE.md +++ b/LOCAL_TESTING_GUIDE.md @@ -1,5 +1,23 @@ # Local Testing Guide for JWT-SVID Authentication +> **⚠️ This guide is stale after kagenti-extensions#411.** It was written +> for the pre-#411 multi-sidecar shape (separate `client-registration`, +> `envoy-with-processor`, and standalone `spiffe-helper` containers) and +> several of its YAML examples reference images that no longer publish. +> The image inventory at the top, the in-line pod spec, and the +> "Check ... logs" sections all assume the legacy shape. +> +> **What still works**: the script invocation in §1 — `local-build-and-test.sh` +> has been updated to build the three combined images (`authbridge`, +> `authbridge-envoy`, `authbridge-lite`) plus `proxy-init`. The rest of +> the guide needs a re-author against the operator-injected combined +> sidecar shape; track via a follow-up issue. +> +> **Working alternatives in the meantime**: +> - `authbridge/demos/weather-agent/demo-ui-advanced.md` — current +> reference for the combined-sidecar flow with SPIFFE. +> - `authbridge/demos/webhook/README.md` — webhook-injected demo. + This guide walks you through testing JWT-SVID authentication using local images (no push to ghcr.io). ## ⚠️ Important: Use the Build Script diff --git a/README.md b/README.md index c7d177f1f..95990dab5 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,25 @@ See the [AuthBridge README](./authbridge/README.md) for architecture details and ## Container Images -All images are published to `ghcr.io/kagenti/kagenti-extensions/`: +All images are published to `ghcr.io/kagenti/kagenti-extensions/`. After +kagenti-extensions#411 the unified binary was split into three +mode-specific combined images, and the per-component sidecars +(`client-registration`, standalone `spiffe-helper`) were retired: | Image | Description | |-------|-------------| -| `authbridge-unified` | Unified Envoy + authbridge binary (recommended) | -| `authbridge` | Combined sidecar (Envoy + authbridge + spiffe-helper + client-registration) | -| `proxy-init` | Alpine + iptables init container | -| `client-registration` | Python Keycloak client registrar | -| `spiffe-helper` | Fetches SPIFFE credentials from SPIRE | -| `auth-proxy` | Example pass-through proxy (for demos) | -| `demo-app` | Demo target service | +| `authbridge` | proxy-sidecar combined (default): authbridge-proxy + bundled spiffe-helper, full plugin set | +| `authbridge-envoy` | envoy-sidecar combined: Envoy + ext_proc + bundled spiffe-helper, full plugin set | +| `authbridge-lite` | proxy-sidecar shape, auth-only plugins (no parsers) for size-constrained deployments | +| `proxy-init` | Alpine + iptables init container (envoy-sidecar mode only) | + +`spiffe-helper` is bundled inside each combined image and gated per +workload by the `SPIRE_ENABLED` env var. Client registration is +handled by the kagenti-operator's `ClientRegistrationReconciler` and +no longer ships as a separate image. The legacy `authbridge-unified`, +`authbridge-light`, `client-registration`, and standalone `spiffe-helper` +images are no longer published; older release tags continue to +publish the previous shape. ## Development diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index 7f187e91e..c1c312f1a 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -129,7 +129,7 @@ wants to register. - The operator-supplied env vars (`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `TOKEN_URL`, `ISSUER`, `DEFAULT_OUTBOUND_POLICY`, `CLIENT_ID`) are consumed by the default `authbridge-combined.yaml` via `${VAR}` expansion — they land inside the appropriate plugin's `config:` block rather than a top-level section. - `jwt-validation` derives `jwks_url` from `issuer` when omitted (appends `/protocol/openid-connect/certs`). - `token-exchange` derives `token_url` from `keycloak_url + keycloak_realm` when omitted (Keycloak convention). -- Credential files: the **kagenti-operator** registers each workload with Keycloak and creates a Secret containing `client-id.txt` + `client-secret.txt`; the operator's webhook mounts that Secret at `/shared/client-id.txt` and `/shared/client-secret.txt` in containers that share the `shared-data` volume. `spiffe-helper` (bundled in both combined images, started conditionally on `SPIRE_ENABLED=true`) writes `/opt/jwt_svid.token`. `jwt-validation` reads the audience from `/shared/client-id.txt` via `audience_file`; `token-exchange` reads client credentials via `client_id_file` / `client_secret_file` / `jwt_svid_path`. Each plugin attempts a synchronous read at Configure time and falls back to a background poll from its `Init` goroutine if the file isn't yet readable. The legacy in-pod `client-registration` sidecar has been removed; workloads opting back into that path use `kagenti.io/client-registration-inject: "true"` and the operator's bundled client-registration image (separate repo). +- Credential files: the **kagenti-operator** registers each workload with Keycloak and creates a Secret containing `client-id.txt` + `client-secret.txt`; the operator's webhook mounts that Secret at `/shared/client-id.txt` and `/shared/client-secret.txt` in containers that share the `shared-data` volume. `spiffe-helper` (bundled in both combined images, started conditionally on `SPIRE_ENABLED=true`) writes `/opt/jwt_svid.token`. `jwt-validation` reads the audience from `/shared/client-id.txt` via `audience_file`; `token-exchange` reads client credentials via `client_id_file` / `client_secret_file` / `jwt_svid_path`. Each plugin attempts a synchronous read at Configure time and falls back to a background poll from its `Init` goroutine if the file isn't yet readable. The legacy in-pod `client-registration` sidecar has been removed entirely; the `kagenti.io/client-registration-inject: "true"` label is **no longer functional** — the operator's `ClientRegistrationReconciler` still treats it as a "skip operator-managed registration" signal (`SkipReason` in `kagenti-operator/internal/clientreg/names.go:58`), but the legacy sidecar that the label deferred to is gone. Setting it today silently breaks registration; do not add it to new manifests. - Outbound route config: `token-exchange` reads `/etc/authproxy/routes.yaml` by default (path is per-plugin, configured via `routes.file` in its config block); inline rules can be declared under `routes.rules`. - Outbound `default_policy`: `passthrough` (default) or `exchange`, configured per-plugin (no top-level `DEFAULT_OUTBOUND_POLICY` field anymore; the env var is still expanded into the plugin config by `authbridge-combined.yaml`). @@ -224,11 +224,11 @@ When the webhook injects sidecars (via [kagenti-operator](https://github.com/kag | Resource | Kind | Consumer | Key Fields | |----------|------|----------|------------| -| `authbridge-config` | ConfigMap | client-registration, authbridge | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `PLATFORM_CLIENT_IDS` (optional), `TOKEN_URL` (optional, derived), `ISSUER` (optional, derived or explicit), `DEFAULT_OUTBOUND_POLICY` (optional). Inbound audience validation uses `CLIENT_ID` from `/shared/client-id.txt`. Target audience and scopes are configured per-route in `authproxy-routes`. | -| `keycloak-admin-secret` | Secret | client-registration | `KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD` | +| `authbridge-config` | ConfigMap | authbridge | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `PLATFORM_CLIENT_IDS` (optional), `TOKEN_URL` (optional, derived), `ISSUER` (optional, derived or explicit), `DEFAULT_OUTBOUND_POLICY` (optional). Inbound audience validation uses `CLIENT_ID` from `/shared/client-id.txt`. Target audience and scopes are configured per-route in `authproxy-routes`. | +| `keycloak-admin-secret` | Secret | kagenti-operator (ClientRegistrationReconciler) | `KEYCLOAK_ADMIN_USERNAME`, `KEYCLOAK_ADMIN_PASSWORD` | | `authproxy-routes` | ConfigMap (optional) | authbridge | `routes.yaml` with per-host token exchange rules | -| `spiffe-helper-config` | ConfigMap | spiffe-helper | `helper.conf` (SPIRE agent address, cert paths, JWT SVID config) | -| `envoy-config` | ConfigMap | envoy (in authbridge-unified image) | `envoy.yaml` (full Envoy configuration) | +| `spiffe-helper-config` | ConfigMap | spiffe-helper (bundled inside the combined sidecar images) | `helper.conf` (SPIRE agent address, cert paths, JWT SVID config) | +| `envoy-config` | ConfigMap | Envoy (inside the `authbridge-envoy` combined image, envoy-sidecar mode only) | `envoy.yaml` (full Envoy configuration) | **`authproxy-routes` format** (`routes.yaml`): ```yaml diff --git a/authbridge/README.md b/authbridge/README.md index 2d487712f..dc39e56c7 100644 --- a/authbridge/README.md +++ b/authbridge/README.md @@ -8,32 +8,25 @@ AuthBridge provides **secure, transparent token management** for Kubernetes work The [`cmd/authbridge/`](./cmd/authbridge/) directory contains a unified binary that supports three deployment modes in a single codebase. Two container images are published: -| Image | Size | Contents | -|-------|------|----------| -| `authbridge-envoy` | 140 MB | Go binary + Envoy (UBI9-micro base) | -| `authbridge-light` | 29 MB | Go binary only (distroless base) | +| Image | Contents | +|-------|----------| +| `authbridge` | proxy-sidecar combined: authbridge-proxy binary + bundled spiffe-helper | +| `authbridge-envoy` | envoy-sidecar combined: Envoy + ext_proc + bundled spiffe-helper | +| `authbridge-lite` | proxy-sidecar shape, auth-only plugins (no parsers) | | Mode | Image | Use Case | How It Works | |------|-------|----------|-------------| -| `envoy-sidecar` (default) | `authbridge-envoy` | Transparent interception via iptables | Envoy intercepts all traffic, delegates auth to authbridge via ext_proc gRPC | -| `proxy-sidecar` | `authbridge-light` | Lightweight proxy via HTTP_PROXY env | Agent routes outbound traffic through forward proxy; reverse proxy validates inbound JWTs | -| `waypoint` | `authbridge-light` | Shared proxy in Istio ambient mesh | Standalone deployment (not injected as sidecar) | +| `proxy-sidecar` (default) | `authbridge` | HTTP_PROXY-based forward + reverse proxies | Agent routes outbound traffic through forward proxy; reverse proxy validates inbound JWTs | +| `envoy-sidecar` | `authbridge-envoy` | Transparent interception via iptables | Envoy intercepts all traffic, delegates auth to authbridge via ext_proc gRPC | +| `lite` | `authbridge-lite` | Same shape as proxy-sidecar with auth-only plugins (no parsers) | For size-constrained deployments that don't need protocol-aware session events | -The operator selects the mode via the `kagenti.io/authbridge-mode` annotation on the workload. Default is `envoy-sidecar`. See [kagenti-operator PR #295](https://github.com/kagenti/kagenti-operator/pull/295) for the implementation. - -### Image naming - -| Name | Status | -|------|--------| -| `authbridge-envoy` | Canonical name for the Envoy variant | -| `authbridge-light` | Lightweight variant (no Envoy) | -| `authbridge-unified` | Deprecated alias for `authbridge-envoy` | +The kagenti-operator resolves the mode per workload from `AgentRuntime.Spec.AuthBridgeMode` → namespace ConfigMap → deprecated `kagenti.io/authbridge-mode` annotation → cluster default (`proxy-sidecar`). See kagenti-operator#361. The shared auth library at [`authlib/`](./authlib/) contains the building blocks (JWT validation, token exchange, caching, routing) with no protocol dependencies. See [`authlib/README.md`](./authlib/README.md) for package reference. ## Architecture (Operator-Injected) -The following describes the operator-injected sidecar deployment. The `authbridge-envoy` image replaces the legacy `envoy-with-processor` in envoy-sidecar mode. The `authbridge-light` image is used for proxy-sidecar mode. +The following describes the operator-injected sidecar deployment. After kagenti-extensions#411 each mode is served by its own combined image (one container per pod, with `spiffe-helper` bundled inside and gated by `SPIRE_ENABLED`). The legacy `authbridge-unified`, `authbridge-light`, `envoy-with-processor`, and standalone `client-registration` / `spiffe-helper` sidecars are gone. ### What AuthBridge Does diff --git a/authbridge/authproxy/k8s/auth-proxy-deployment.yaml b/authbridge/authproxy/k8s/auth-proxy-deployment.yaml index 32b220721..b09cf3d61 100644 --- a/authbridge/authproxy/k8s/auth-proxy-deployment.yaml +++ b/authbridge/authproxy/k8s/auth-proxy-deployment.yaml @@ -1,3 +1,10 @@ +# ⚠️ BROKEN AFTER kagenti-extensions#411 — references +# localhost/authbridge-unified:latest which no longer publishes; the +# unified binary was split into mode-specific combined images +# (authbridge / authbridge-envoy / authbridge-lite). Applying this +# YAML today will produce ImagePullBackOff. The standalone authproxy +# quickstart needs migration to the combined sidecar shape; use the +# webhook or weather-agent demos in the meantime. apiVersion: apps/v1 kind: Deployment metadata: diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index e48d10086..91500d575 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -4,9 +4,12 @@ This directory contains demo scenarios showing AuthBridge providing zero-trust authentication for Kubernetes agent workloads. Each demo progressively introduces more AuthBridge capabilities. -> **Note:** These demos use the `authbridge-unified` image with operator-injected -> sidecars. See [`cmd/authbridge/README.md`](../cmd/authbridge/README.md) for details -> on the unified authbridge binary. +> **Note:** These demos use the operator-injected combined sidecar (after +> kagenti-extensions#411 — one image per mode: `authbridge` for proxy-sidecar, +> `authbridge-envoy` for envoy-sidecar, `authbridge-lite` for the auth-only +> shape). The previous `authbridge-unified` image and the per-component +> sidecars (`client-registration`, standalone `spiffe-helper`) have been +> removed. ## Available Demos @@ -16,8 +19,17 @@ more AuthBridge capabilities. | **[Weather Agent (advanced)](weather-agent/demo-ui-advanced.md)** | Intermediate | Inbound on agent **and** tool, outbound token exchange, ingress JWT verification on the tool | [kubectl + script](weather-agent/demo-ui-advanced.md#automated-deploy-and-verify-ci-oriented) | | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | | **[Webhook](webhook/README.md)** | Intermediate | Webhook-based sidecar injection with auth-target demo app | Manual | -| **[Single Target](single-target/demo.md)** | Advanced | Manual AuthBridge deployment (no webhook) with SPIFFE identity | Manual | -| **[Multi-Target](multi-target/demo.md)** | Advanced | Route-based token exchange to multiple target services | Manual | +| ~~**[Single Target](single-target/demo.md)**~~ ⚠️ | Advanced | Manual AuthBridge deployment (no webhook) with SPIFFE identity | Manual | +| ~~**[Multi-Target](multi-target/demo.md)**~~ ⚠️ | Advanced | Route-based token exchange to multiple target services | Manual | + +> **⚠️ Single Target / Multi-Target demos are currently broken** after +> kagenti-extensions#411. The YAMLs use the pre-#411 multi-sidecar pattern +> (separate `envoy-proxy`, `authbridge-light`, `client-registration`, and +> `spiffe-helper` containers) with images that no longer publish — applying +> them yields ImagePullBackOff. They need migration to the combined sidecar +> shape (one `authbridge` / `authbridge-envoy` container) before they're +> usable again. Use the **Webhook** or **Weather Agent** demos in the +> meantime. ## Recommended Path diff --git a/authbridge/demos/multi-target/demo.md b/authbridge/demos/multi-target/demo.md index 5d56bc36b..475474b64 100644 --- a/authbridge/demos/multi-target/demo.md +++ b/authbridge/demos/multi-target/demo.md @@ -1,5 +1,18 @@ # Multi-Target Demo +> **⚠️ This demo is currently broken after kagenti-extensions#411.** The +> manifests in `k8s/` use the pre-#411 multi-sidecar pattern with images +> that no longer publish (`authbridge-unified`, `client-registration`, +> standalone `spiffe-helper`). Applying them yields ImagePullBackOff. +> The build instructions below also reference Dockerfile paths that no +> longer exist (`cmd/authbridge/Dockerfile`). +> +> The demo concept (route-based token exchange to multiple targets) +> still applies, but the YAMLs need migration to the combined sidecar +> shape (one `authbridge` / `authbridge-envoy` container per pod). Use +> `authbridge/demos/weather-agent/demo-ui-advanced.md` or +> `authbridge/demos/webhook/README.md` in the meantime. + This demo shows AuthBridge performing route-based token exchange to multiple target services. ## Overview 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 123868cd7..aee70b9e2 100644 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml +++ b/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml @@ -1,3 +1,7 @@ +# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the +# single-target authbridge-deployment.yaml. See its header for the +# dead images. Applying this YAML today will produce ImagePullBackOff. +# # AuthBridge Demo Deployment (No SPIFFE) # # This is a simplified version that doesn't require SPIRE. diff --git a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml b/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml index 9f21298ff..cd158aef4 100644 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml +++ b/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml @@ -1,3 +1,7 @@ +# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the +# single-target authbridge-deployment.yaml. See its header for the +# dead images. Applying this YAML today will produce ImagePullBackOff. +# # AuthBridge Demo Deployment (with SPIFFE) # # This deployment demonstrates: diff --git a/authbridge/demos/single-target/demo.md b/authbridge/demos/single-target/demo.md index 4ce76b6e2..737f86941 100644 --- a/authbridge/demos/single-target/demo.md +++ b/authbridge/demos/single-target/demo.md @@ -1,5 +1,14 @@ # AuthBridge Demo Guide +> **⚠️ This demo is currently broken after kagenti-extensions#411.** The +> manifests in `k8s/` use the pre-#411 multi-sidecar pattern with images +> that no longer publish (`authbridge-unified`, `client-registration`, +> standalone `spiffe-helper`). Applying them yields ImagePullBackOff. +> The demo needs migration to the combined sidecar shape (one +> `authbridge` / `authbridge-envoy` container per pod). Use +> `authbridge/demos/weather-agent/demo-ui-advanced.md` or +> `authbridge/demos/webhook/README.md` in the meantime. + This guide provides step-by-step instructions for running the AuthBridge demo. > **📘 New to AuthBridge?** See the [AuthBridge README](../../README.md) for an overview of what AuthBridge does and how it works. 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 4d173963a..1b0106f7a 100644 --- a/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml +++ b/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml @@ -1,3 +1,8 @@ +# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the +# matching SPIFFE-enabled deployment in this directory. See +# authbridge-deployment.yaml's header for the dead images. Applying +# this YAML today will produce ImagePullBackOff. +# # AuthBridge Demo Deployment (No SPIFFE) # # This is a simplified version that doesn't require SPIRE. diff --git a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml b/authbridge/demos/single-target/k8s/authbridge-deployment.yaml index 64314dace..8f0f0f7cb 100644 --- a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml +++ b/authbridge/demos/single-target/k8s/authbridge-deployment.yaml @@ -1,3 +1,16 @@ +# ⚠️ BROKEN AFTER kagenti-extensions#411 — see ../demo.md and the demos +# index README. This manifest uses the pre-#411 multi-sidecar pattern +# (envoy-proxy + authbridge-light + client-registration + standalone +# spiffe-helper as separate containers). Several of those images no +# longer publish: +# - localhost/authbridge-unified:latest (image gone — replaced by authbridge / authbridge-envoy combined images) +# - ghcr.io/kagenti/kagenti-extensions/client-registration:latest (sidecar replaced by operator-managed registration) +# - ghcr.io/spiffe/spiffe-helper:nightly (now bundled inside the combined sidecar images, started by SPIRE_ENABLED) +# - localhost/auth-proxy:latest (demo-app image; rebuild from authbridge/authproxy/quickstart/) +# Applying this YAML today will produce ImagePullBackOff. Migration to +# the combined-sidecar shape is tracked separately. Use the webhook or +# weather-agent demos in the meantime. +# # AuthBridge Demo Deployment (with SPIFFE) # # This deployment demonstrates: diff --git a/authbridge/demos/weather-agent/demo-ui.md b/authbridge/demos/weather-agent/demo-ui.md index 8792e3968..8df6d2370 100644 --- a/authbridge/demos/weather-agent/demo-ui.md +++ b/authbridge/demos/weather-agent/demo-ui.md @@ -646,13 +646,13 @@ kubectl rollout status deployment weather-service -n team1 --timeout=120s ### Key differences -| | envoy-sidecar (default) | proxy-sidecar | +| | proxy-sidecar (default) | envoy-sidecar | |---|---|---| -| Image | `authbridge-envoy` (140 MB) | `authbridge-light` (29 MB) | -| Traffic interception | iptables + Envoy | HTTP_PROXY env vars | -| Init container | proxy-init (NET_ADMIN) | None | -| Container name | `envoy-proxy` | `authbridge-proxy` | -| Ollama port exclusion | Required (annotation) | Not needed | +| Image | `authbridge` (combined) | `authbridge-envoy` (combined) | +| Traffic interception | HTTP_PROXY env vars | iptables + Envoy | +| Init container | None | `proxy-init` (NET_ADMIN) | +| Container name | `authbridge-proxy` | `envoy-proxy` | +| Ollama port exclusion | Not needed | Required (annotation) | > **Note:** Proxy-sidecar mode requires the agent to read the `PORT` env var. > All agents in [kagenti/agent-examples](https://github.com/kagenti/agent-examples) diff --git a/authbridge/demos/webhook/README.md b/authbridge/demos/webhook/README.md index 0bbabe47b..c81268b9f 100644 --- a/authbridge/demos/webhook/README.md +++ b/authbridge/demos/webhook/README.md @@ -6,25 +6,22 @@ This guide demonstrates how the **kagenti-operator** webhook automatically injec ## Overview -The operator webhook watches for deployments with the `kagenti.io/inject: enabled` label and automatically injects AuthBridge sidecars. There are two injection modes controlled by the `combinedSidecar` feature gate: +The operator webhook watches for deployments with the `kagenti.io/inject: enabled` label and automatically injects an AuthBridge sidecar. After kagenti-extensions#411 there is a single combined sidecar per mode (no more separate `envoy-proxy` + `spiffe-helper` + `client-registration` containers); the legacy multi-sidecar shape and the `combinedSidecar` feature gate are gone. The operator picks the mode per workload via `AgentRuntime.Spec.AuthBridgeMode` → namespace ConfigMap → deprecated annotation → cluster default (`proxy-sidecar`). -### Separate mode (default: `combinedSidecar: false`) +### proxy-sidecar mode (default) | Container | Purpose | |-----------|---------| -| `proxy-init` | Init container that sets up iptables to redirect inbound and outbound traffic | -| `spiffe-helper` | Fetches SPIFFE credentials from SPIRE (only with `kagenti.io/spire: enabled`) | -| `kagenti-client-registration` | Registers the workload with Keycloak (using SPIFFE ID or static client ID) | -| `envoy-proxy` | Intercepts inbound HTTP requests (JWT validation) and outbound requests (HTTP: token exchange; HTTPS: TLS passthrough) | +| `authbridge-proxy` | Combined sidecar from the `authbridge` image. Runs the HTTP forward + reverse proxies with bundled `spiffe-helper`, gated per workload by `SPIRE_ENABLED`. | -### Combined mode (`combinedSidecar: true`) +### envoy-sidecar mode | Container | Purpose | |-----------|---------| -| `proxy-init` | Init container that sets up iptables (same as separate mode) | -| `authbridge` | Single sidecar combining Envoy, authbridge, spiffe-helper, and client-registration | +| `proxy-init` | Init container that sets up iptables to redirect inbound and outbound traffic | +| `envoy-proxy` | Combined sidecar from the `authbridge-envoy` image. Runs Envoy + ext_proc + bundled `spiffe-helper`. | -Combined mode reduces per-pod overhead from 3 long-running sidecars to 1, simplifies debugging, and speeds up pod startup. See [Enabling Combined Sidecar Mode](#enabling-combined-sidecar-mode) below. +In both modes, Keycloak client registration is handled by the kagenti-operator's `ClientRegistrationReconciler` and the resulting `kagenti-keycloak-client-credentials-` Secret is mounted at `/shared/` by the webhook. ## Architecture @@ -68,10 +65,9 @@ Combined mode reduces per-pod overhead from 3 long-running sidecars to 1, simpli 2. **Keycloak** deployed in the `keycloak` namespace 3. **SPIRE** deployed (optional, for SPIFFE-based identity) 4. **AuthBridge images** available from GitHub Container Registry: - - `ghcr.io/kagenti/kagenti-extensions/proxy-init:latest` - - `ghcr.io/kagenti/kagenti-extensions/authbridge-unified:latest` - - `ghcr.io/kagenti/kagenti-extensions/demo-app:latest` - - `ghcr.io/kagenti/kagenti-extensions/client-registration:latest` + - `ghcr.io/kagenti/kagenti-extensions/authbridge:latest` (proxy-sidecar combined image) + - `ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest` (envoy-sidecar combined image) + - `ghcr.io/kagenti/kagenti-extensions/proxy-init:latest` (envoy-sidecar mode only) --- @@ -114,60 +110,32 @@ The ConfigMaps include: **Note**: All labels must be on the **Pod template** (`spec.template.metadata.labels`), not the Deployment metadata. -## Enabling Combined Sidecar Mode - -To use the combined `authbridge` container instead of separate sidecars, enable the `combinedSidecar` feature gate: +## Mode selection -### Via operator Helm values +After kagenti-extensions#411 / kagenti-operator#361 the operator resolves AuthBridge mode per workload from this chain: -See the [kagenti-operator docs](https://github.com/kagenti/kagenti-operator) for Helm-based configuration. +1. `AgentRuntime.Spec.AuthBridgeMode` on the workload's CR (canonical surface). +2. `mode:` field on the namespace-level `authbridge-runtime-config` ConfigMap. +3. The deprecated `kagenti.io/authbridge-mode` pod annotation (still honored). +4. Cluster-wide default — `proxy-sidecar`. -### Via ConfigMap (for existing deployments) - -```bash -# Edit the feature gates ConfigMap directly -kubectl edit configmap kagenti-webhook-feature-gates -n kagenti-system -``` +The previous `combinedSidecar` feature gate and the `envoyProxy` / `spiffeHelper` / `clientRegistration` per-sidecar gates were removed; their behavior was the legacy multi-sidecar shape that no longer exists. The legacy `kagenti.io/client-registration-inject: "true"` label is **no longer functional** — setting it today silently disables registration (the operator's `SkipReason` still honors it and steps aside, but the in-pod sidecar that was supposed to take over is gone). Do not add it to new manifests. -Add `combinedSidecar: true` to the `feature-gates.yaml` data key: +To pick a mode for a specific workload, set it on the AgentRuntime CR: ```yaml -data: - feature-gates.yaml: | - globalEnabled: true - envoyProxy: true - spiffeHelper: true - clientRegistration: true - combinedSidecar: true +apiVersion: kagenti.io/v1alpha1 +kind: AgentRuntime +metadata: + name: my-agent +spec: + authBridgeMode: envoy-sidecar # or proxy-sidecar / lite / waypoint + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: my-agent ``` -The webhook watches this ConfigMap for changes and reloads automatically. New pods created after the change will use combined mode. Existing pods are not affected — delete and recreate them to switch. - -### What changes - -| Aspect | Separate mode | Combined mode | -|--------|---------------|---------------| -| Sidecar containers | 3 (`envoy-proxy`, `spiffe-helper`, `kagenti-client-registration`) | 1 (`authbridge`) | -| Init containers | 1 (`proxy-init`) | 1 (`proxy-init`) | -| Container to read credentials | `-c envoy-proxy` | `-c authbridge` | -| Container for Envoy logs | `-c envoy-proxy` | `-c authbridge` | -| Per-sidecar opt-out labels | Each sidecar can be independently disabled | `spiffeHelper` and `clientRegistration` are passed as flags to the entrypoint; `envoy-proxy` disabled = no combined container | -| Image | `authbridge-unified` + `spiffe-helper` + `client-registration` | `authbridge` (single image) | - -### Per-sidecar control in combined mode - -When `combinedSidecar: true`, the per-sidecar feature gates and workload labels still work: - -- **`spiffeHelper: false`** or `kagenti.io/spiffe-helper-inject: "false"`: The combined container starts with `SPIRE_ENABLED=false` — spiffe-helper is not launched, and a static client ID is used instead. -- **`clientRegistration: false`**: The combined container starts with `CLIENT_REGISTRATION_ENABLED=false` — client registration is skipped. -- **Client registration**: operator-managed only. The legacy - `kagenti.io/client-registration-inject: "true"` label opted into an - in-pod `kagenti-client-registration` sidecar that was removed in - #411 — setting it today silently disables registration entirely - (the operator's `SkipReason` honors the label and steps aside, and - the legacy sidecar isn't there to take over). Don't set it. -- **`envoyProxy: false`** or `kagenti.io/envoy-proxy-inject: "false"`: No combined container is injected at all (the proxy is the core component). - Then continue with: - [Step 1: Setup Keycloak](#step-1-setup-keycloak) - Configure Keycloak clients and scopes - [Step 2: Deploy Auth Target and Agent](#step-2-deploy-auth-target-and-agent) - Deploy the demo workloads @@ -365,20 +333,21 @@ kubectl describe pod -l app=agent -n team1 ### Check Container Logs -**Separate mode** (default): +**proxy-sidecar mode** (default): ```bash -kubectl logs deployment/agent -n team1 -c kagenti-client-registration -kubectl logs deployment/agent -n team1 -c envoy-proxy | grep -E "(Token Exchange|error)" -kubectl logs deployment/agent -n team1 -c spiffe-helper +# All sidecar logs are in one container — the combined image bundles +# spiffe-helper internally, and operator-managed client registration +# logs live in the kagenti-operator deployment in kagenti-system. +kubectl logs deployment/agent -n team1 -c authbridge-proxy +kubectl logs deployment/agent -n team1 -c authbridge-proxy | grep -iE "token.exchange|error" ``` -**Combined mode** (`combinedSidecar: true`): +**envoy-sidecar mode**: ```bash -# All sidecar logs are in one container -kubectl logs deployment/agent -n team1 -c authbridge -# Filter by component -kubectl logs deployment/agent -n team1 -c authbridge | grep "\[AuthBridge\]" -kubectl logs deployment/agent -n team1 -c authbridge | grep "Token Exchange" +kubectl logs deployment/agent -n team1 -c envoy-proxy +kubectl logs deployment/agent -n team1 -c envoy-proxy | grep -iE "token.exchange|error" +# Operator-managed registration: +kubectl logs deployment/kagenti-controller-manager -n kagenti-system | grep -i clientregistration ``` ### Common Issues @@ -392,14 +361,10 @@ kubectl logs deployment/agent -n team1 -c authbridge | grep "Token Exchange" 3. **Image pull errors** - Images are automatically pulled from `ghcr.io/kagenti/kagenti-extensions/` - - If you need to build locally for development: - ```bash - cd authbridge/authproxy - make build - # Load into Kind cluster - kind load docker-image --name localhost/proxy-init:latest - kind load docker-image --name localhost/authbridge-unified:latest - ``` + - If you need to build locally for development, see `local-build-and-test.sh` + at the repo root, which builds the post-#411 image set + (`authbridge`, `authbridge-envoy`, `authbridge-lite`, `proxy-init`) + and loads them into the cluster. - See the [kagenti-operator](https://github.com/kagenti/kagenti-operator) for image configuration 4. **SPIFFE credentials not ready** diff --git a/local-build-and-test.sh b/local-build-and-test.sh index f5d147c7a..81f9f9661 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -68,11 +68,13 @@ echo "" # Build authbridge (proxy-sidecar combined: authbridge-proxy + spiffe-helper) # Default deployment shape — used when the workload's mode is proxy-sidecar. +# After kagenti-extensions#411 the unified binary was split into three +# mode-specific binaries; each has its own Dockerfile under cmd/authbridge-*/. echo "==========================================" echo "Building authbridge (proxy-sidecar combined)" echo "==========================================" cd "${SCRIPT_DIR}/authbridge" -${CONTAINER_RUNTIME} build -f cmd/authbridge/Dockerfile.proxy -t ghcr.io/kagenti/kagenti-extensions/authbridge:local . +${CONTAINER_RUNTIME} build -f cmd/authbridge-proxy/Dockerfile -t ghcr.io/kagenti/kagenti-extensions/authbridge:local . load_image_to_kind ghcr.io/kagenti/kagenti-extensions/authbridge:local echo "✅ Built and loaded: authbridge:local" echo "" @@ -82,11 +84,21 @@ echo "==========================================" echo "Building authbridge-envoy (envoy-sidecar combined)" echo "==========================================" cd "${SCRIPT_DIR}/authbridge" -${CONTAINER_RUNTIME} build -f cmd/authbridge/Dockerfile.envoy -t ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local . +${CONTAINER_RUNTIME} build -f cmd/authbridge-envoy/Dockerfile -t ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local . load_image_to_kind ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local echo "✅ Built and loaded: authbridge-envoy:local" echo "" +# Build authbridge-lite (proxy-sidecar shape, auth-only plugins, no parsers) +echo "==========================================" +echo "Building authbridge-lite (proxy-sidecar lite)" +echo "==========================================" +cd "${SCRIPT_DIR}/authbridge" +${CONTAINER_RUNTIME} build -f cmd/authbridge-lite/Dockerfile -t ghcr.io/kagenti/kagenti-extensions/authbridge-lite:local . +load_image_to_kind ghcr.io/kagenti/kagenti-extensions/authbridge-lite:local +echo "✅ Built and loaded: authbridge-lite:local" +echo "" + # Build proxy-init (iptables init container, used by envoy-sidecar mode only) echo "==========================================" echo "Building proxy-init" @@ -105,6 +117,7 @@ echo "Images loaded into cluster '${CLUSTER_NAME}':" echo " - ghcr.io/kagenti/kagenti/spiffe-idp-setup:local" echo " - ghcr.io/kagenti/kagenti-extensions/authbridge:local" echo " - ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:local" +echo " - ghcr.io/kagenti/kagenti-extensions/authbridge-lite:local" echo " - ghcr.io/kagenti/kagenti-extensions/proxy-init:local" echo "" echo "Next steps:" From fa18e331f6986fe4da8c96a2fbf7f3542b6261d9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 17:06:28 -0400 Subject: [PATCH 3/9] chore: address PR review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small follow-ups from the review: * local-build-and-test.sh: bump `set -e` to `set -euo pipefail` to match the kagenti shell convention (CLAUDE.md). One unguarded variable surfaced under `-u` — `KIND_EXPERIMENTAL_PROVIDER` at the podman-detect branch — fixed with `${KIND_EXPERIMENTAL_PROVIDER:-}`. All other env-var reads were already either `${VAR:-default}` or guaranteed-set by upstream defaults. Verified: bash -n + a smoke run against a missing KAGENTI_DIR exits at the expected error message (no -u trip). * local-build-and-test.sh: quote the `${image_name}` argument to `echo` inside the tar-file path expansion, in case an image name ever contains whitespace. Cosmetic given current names but free. * authbridge/demos/webhook/README.md: lead the feature-gate-removal paragraph with a single-line conclusion ("All four feature gates ... are removed.") so a skimmer sees the bottom line before the explanatory clause. * authbridge/demos/README.md: strikethrough on the broken Single-Target / Multi-Target rows is invisible to assistive tech. Add an explicit "(broken — see below)" text suffix on each row so screen readers and non-rendering viewers also get the cue. Skipped from the review: * PR title rename suggestion. Reviewer agreed current title is defensible ("not blocking"); commit 1 is a real bug fix on a user-facing CI path, so leading with `fix:` matches the most important half. Keeping it. Verified: bash -n on the script and a runtime invocation through the early exit; the README markdown changes are pure prose. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/README.md | 4 ++-- authbridge/demos/webhook/README.md | 2 +- local-build-and-test.sh | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index 91500d575..ffc034222 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -19,8 +19,8 @@ more AuthBridge capabilities. | **[Weather Agent (advanced)](weather-agent/demo-ui-advanced.md)** | Intermediate | Inbound on agent **and** tool, outbound token exchange, ingress JWT verification on the tool | [kubectl + script](weather-agent/demo-ui-advanced.md#automated-deploy-and-verify-ci-oriented) | | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | | **[Webhook](webhook/README.md)** | Intermediate | Webhook-based sidecar injection with auth-target demo app | Manual | -| ~~**[Single Target](single-target/demo.md)**~~ ⚠️ | Advanced | Manual AuthBridge deployment (no webhook) with SPIFFE identity | Manual | -| ~~**[Multi-Target](multi-target/demo.md)**~~ ⚠️ | Advanced | Route-based token exchange to multiple target services | Manual | +| ~~**[Single Target](single-target/demo.md)**~~ ⚠️ **(broken — see below)** | Advanced | Manual AuthBridge deployment (no webhook) with SPIFFE identity | Manual | +| ~~**[Multi-Target](multi-target/demo.md)**~~ ⚠️ **(broken — see below)** | Advanced | Route-based token exchange to multiple target services | Manual | > **⚠️ Single Target / Multi-Target demos are currently broken** after > kagenti-extensions#411. The YAMLs use the pre-#411 multi-sidecar pattern diff --git a/authbridge/demos/webhook/README.md b/authbridge/demos/webhook/README.md index c81268b9f..f806ad147 100644 --- a/authbridge/demos/webhook/README.md +++ b/authbridge/demos/webhook/README.md @@ -119,7 +119,7 @@ After kagenti-extensions#411 / kagenti-operator#361 the operator resolves AuthBr 3. The deprecated `kagenti.io/authbridge-mode` pod annotation (still honored). 4. Cluster-wide default — `proxy-sidecar`. -The previous `combinedSidecar` feature gate and the `envoyProxy` / `spiffeHelper` / `clientRegistration` per-sidecar gates were removed; their behavior was the legacy multi-sidecar shape that no longer exists. The legacy `kagenti.io/client-registration-inject: "true"` label is **no longer functional** — setting it today silently disables registration (the operator's `SkipReason` still honors it and steps aside, but the in-pod sidecar that was supposed to take over is gone). Do not add it to new manifests. +**All four feature gates (`combinedSidecar`, `envoyProxy`, `spiffeHelper`, `clientRegistration`) are removed.** Their behavior was the legacy multi-sidecar shape that no longer exists. The legacy `kagenti.io/client-registration-inject: "true"` label is also **no longer functional** — setting it today silently disables registration (the operator's `SkipReason` still honors it and steps aside, but the in-pod sidecar that was supposed to take over is gone). Do not add it to new manifests. To pick a mode for a specific workload, set it on the AgentRuntime CR: diff --git a/local-build-and-test.sh b/local-build-and-test.sh index 81f9f9661..90ea8b829 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Local Build and Test Script for JWT-SVID Authentication # This script builds all necessary images locally and loads them into Kind @@ -14,7 +14,7 @@ CLUSTER_NAME="${CLUSTER_NAME:-kagenti-dev}" # Auto-detect container runtime (Podman or Docker) # If KIND_EXPERIMENTAL_PROVIDER is set to podman, use it regardless of what's installed -if [ "${KIND_EXPERIMENTAL_PROVIDER}" = "podman" ]; then +if [ "${KIND_EXPERIMENTAL_PROVIDER:-}" = "podman" ]; then CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-podman}" elif ! command -v docker &> /dev/null && command -v podman &> /dev/null; then # Docker not available but Podman is @@ -31,7 +31,7 @@ load_image_to_kind() { if [ "${CONTAINER_RUNTIME}" = "podman" ]; then # Podman: save to tar and load # Replace colons and slashes to make valid filename - local tar_file="/tmp/$(echo ${image_name} | sed 's|[:/]|-|g').tar" + local tar_file="/tmp/$(echo "${image_name}" | sed 's|[:/]|-|g').tar" ${CONTAINER_RUNTIME} save "${image_name}" -o "${tar_file}" kind load image-archive "${tar_file}" --name "${CLUSTER_NAME}" rm -f "${tar_file}" From 0626dce5033166a328edfe6da69dbb91a3bcfde0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 17:21:13 -0400 Subject: [PATCH 4/9] docs(demos): refresh weather-agent + webhook for combined sidecar shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical updates to working demos so their prose matches what the operator actually injects post-#411 / kagenti-operator#361 (one combined AuthBridge sidecar, container name depends on resolved mode). Changes: * weather-agent/demo-ui.md - Architecture diagram: collapse 4-box agent-pod (agent + spiffe-helper + client-registration + envoy-proxy) into 2-box (agent + AuthBridge sidecar). Note that container name is mode-dependent (authbridge-proxy / envoy-proxy), spiffe-helper is bundled, and registration is operator-managed out of band. - "Verify pod" section: 4/4 → 2/2; expected `kubectl exec` output `agent kagenti-client-registration envoy-proxy spiffe-helper` becomes either `agent authbridge-proxy` (default) or `agent envoy-proxy` (envoy-sidecar). - "Check client registration" section: replace the `-c kagenti-client-registration` log inspection with a Secret-name-based check + a pointer at the kagenti-operator's controller-manager logs for the actual reconciler activity. - "Agent Pod Not Starting" troubleshooting: drop the `kagenti-client-registration` / `spiffe-helper` -c branches; add a kagenti-controller-manager fallback for stuck registration. - "Proxy-Sidecar Mode" section: rewrite as "Switching modes" — cluster default is now proxy-sidecar (was envoy-sidecar in the old prose); document the four-layer resolution chain (CR → namespace ConfigMap → annotation → cluster default) and show the canonical `kubectl patch agentruntime ... authBridgeMode` surface. Verification block shows expected container names per mode. Switching back drops the override on the AgentRuntime CR. - Inbound-troubleshooting reference: generalize "check envoy-proxy logs" to mention both authbridge-proxy and envoy-proxy. * weather-agent/demo-ui-advanced.md - Drop two `combinedSidecar` references (the feature gate is gone after #411); replace `4/4 (or combined sidecar variant)` with a clean `2/2`. - Hard-coded `-c envoy-proxy` in two log-inspection blocks: the advanced demo runs in envoy-sidecar mode by design, so the primary commands stay on `-c authbridge-proxy` for proxy-sidecar parity, with an inline note that envoy-sidecar uses `envoy-proxy`. * weather-agent/k8s/configmaps-advanced.yaml - One stale comment referenced an `envoy-with-processor` build that no longer ships. Generalize to "AuthBridge ext_proc / forward-proxy" since the same observation applies to both modes after #411. * webhook/README.md - Architecture diagram redrawn for the combined-sidecar shape: one box for the AuthBridge sidecar with explicit per-mode container names. Out-of-band note added about operator-managed Keycloak client registration and the `kagenti-keycloak-client-credentials-` Secret. * webhook/k8s/agent-deployment-webhook.yaml + webhook/k8s/agent-deployment-webhook-no-spiffe.yaml - Top-of-file "containers injected" lists were the old multi- sidecar shape (proxy-init + spiffe-helper + kagenti-client-registration + envoy-proxy). Rewrite to the post-#411 shape (one combined sidecar, container name depends on mode, spiffe-helper bundled, registration operator-managed). The no-spiffe variant explicitly notes that the bundled spiffe-helper stays inactive when SPIRE_ENABLED=false. No structural document changes; the demo flows themselves still work end-to-end. Container-name updates throughout match the operator's `AuthBridgeProxyContainerName = "authbridge-proxy"` / `EnvoyProxyContainerName = "envoy-proxy"` constants. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../demos/weather-agent/demo-ui-advanced.md | 15 +- authbridge/demos/weather-agent/demo-ui.md | 162 ++++++++++++------ .../k8s/configmaps-advanced.yaml | 8 +- authbridge/demos/webhook/README.md | 46 ++--- .../agent-deployment-webhook-no-spiffe.yaml | 15 +- .../webhook/k8s/agent-deployment-webhook.yaml | 19 +- 6 files changed, 166 insertions(+), 99 deletions(-) diff --git a/authbridge/demos/weather-agent/demo-ui-advanced.md b/authbridge/demos/weather-agent/demo-ui-advanced.md index cb492f9e8..c533185a0 100644 --- a/authbridge/demos/weather-agent/demo-ui-advanced.md +++ b/authbridge/demos/weather-agent/demo-ui-advanced.md @@ -218,20 +218,24 @@ If the UI backend cannot yet express the route list, apply ```bash kubectl get pods -n team1 | grep advanced -# Expect 4/4 (or combined sidecar variant) for both workloads when injection is on. +# Expect 2/2 for both workloads when injection is on (proxy-sidecar +# default = agent + authbridge-proxy; envoy-sidecar = agent + envoy-proxy +# plus the proxy-init init container). ``` ### Tool ingress logs ```bash -kubectl logs deployment/weather-tool-advanced -n team1 -c envoy-proxy 2>&1 | grep "\[Inbound\]" -# or -c authbridge when combinedSidecar is enabled +# Container name depends on the resolved AuthBridge mode: +# proxy-sidecar (default): -c authbridge-proxy +# envoy-sidecar: -c envoy-proxy +kubectl logs deployment/weather-tool-advanced -n team1 -c authbridge-proxy 2>&1 | grep "\[Inbound\]" ``` ### Agent outbound exchange logs ```bash -kubectl logs deployment/weather-service-advanced -n team1 -c envoy-proxy 2>&1 | grep -E "Resolver|exchange|Injecting token" +kubectl logs deployment/weather-service-advanced -n team1 -c authbridge-proxy 2>&1 | grep -E "Resolver|exchange|Injecting token" ``` ### CLI token + A2A @@ -280,7 +284,8 @@ Environment variables: | `KEYCLOAK_ADMIN_USERNAME` / `KEYCLOAK_ADMIN_PASSWORD` | For admin REST calls from the verify pod | The script also **warns** if it cannot find obvious inbound/outbound log markers -(lines differ slightly when `combinedSidecar` is enabled). +(the container name it inspects depends on the resolved AuthBridge mode — +`authbridge-proxy` for proxy-sidecar, `envoy-proxy` for envoy-sidecar). ## Cleanup diff --git a/authbridge/demos/weather-agent/demo-ui.md b/authbridge/demos/weather-agent/demo-ui.md index 8df6d2370..2f8e3cb51 100644 --- a/authbridge/demos/weather-agent/demo-ui.md +++ b/authbridge/demos/weather-agent/demo-ui.md @@ -36,22 +36,27 @@ plugin pipeline in real time while chatting with the agent, see │ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ WEATHER-SERVICE POD (namespace: team1) │ │ │ │ │ │ -│ │ ┌──────────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │ │ -│ │ │ weather-service │ │ spiffe- │ │ client-registration │ │ │ -│ │ │ (A2A agent, │ │ helper │ │ (registers with Keycloak │ │ │ -│ │ │ port 8000) │ │ │ │ using SPIFFE ID) │ │ │ -│ │ └──────────────────┘ └─────────────┘ └──────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ AuthProxy Sidecar (envoy-proxy container) │ │ │ -│ │ │ Envoy + ext_proc (authbridge) │ │ │ -│ │ │ Inbound (port 15124): │ │ │ -│ │ │ - Validates JWT (signature + issuer + audience via JWKS) │ │ │ -│ │ │ - Returns 401 Unauthorized for invalid/missing tokens │ │ │ -│ │ │ Outbound (port 15123): │ │ │ -│ │ │ - HTTP: Passthrough (default policy, no token exchange) │ │ │ -│ │ │ - HTTPS: TLS passthrough (no interception) │ │ │ -│ │ └───────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │ │ +│ │ │ weather-service │ │ AuthBridge sidecar (combined image) │ │ │ +│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ +│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy│ │ │ +│ │ └──────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ +│ │ │ │ │ │ +│ │ │ Inbound: │ │ │ +│ │ │ - Validates JWT (signature + issuer + │ │ │ +│ │ │ audience via JWKS) │ │ │ +│ │ │ - Returns 401 for invalid/missing tokens │ │ │ +│ │ │ Outbound: │ │ │ +│ │ │ - HTTP: Passthrough (default policy) │ │ │ +│ │ │ - HTTPS: TLS passthrough (no interception)│ │ │ +│ │ │ │ │ │ +│ │ │ spiffe-helper is bundled inside the image │ │ │ +│ │ │ and gated per-workload by SPIRE_ENABLED. │ │ │ +│ │ │ Keycloak client registration is │ │ │ +│ │ │ operator-managed (no in-pod sidecar); │ │ │ +│ │ │ the operator mounts the resulting Secret │ │ │ +│ │ │ at /shared/client-{id,secret}.txt. │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ Plain HTTP call │(no token exchange) │ @@ -231,13 +236,15 @@ Expected output: ``` NAME READY STATUS RESTARTS AGE -weather-service-58768bdb67-xxxxx 4/4 Running 0 2m +weather-service-58768bdb67-xxxxx 2/2 Running 0 2m weather-tool-7f8c9d6b44-yyyyy 1/1 Running 0 5m ``` -> **Note:** The agent pod should show **4/4** containers — the agent itself plus -> three AuthBridge sidecars (spiffe-helper, kagenti-client-registration, envoy-proxy) -> injected by the webhook. +> **Note:** The agent pod shows **2/2** containers — the agent itself plus +> one combined AuthBridge sidecar (spiffe-helper is bundled inside; client +> registration is handled by the operator outside the pod). In envoy-sidecar +> mode you'll also see a `proxy-init` init container that exits after +> setting up iptables. ### Verify injected containers @@ -245,25 +252,52 @@ weather-tool-7f8c9d6b44-yyyyy 1/1 Running 0 5m kubectl get pod -n team1 -l app.kubernetes.io/name=weather-service -o jsonpath='{.items[0].spec.containers[*].name}' ``` -Expected: +Expected (proxy-sidecar mode, the cluster default): ``` -agent kagenti-client-registration envoy-proxy spiffe-helper +agent authbridge-proxy ``` -### Check client registration +Or, in envoy-sidecar mode: + +``` +agent envoy-proxy +``` + +### Check operator-managed client registration + +After kagenti-extensions#411 / kagenti-operator#361, client registration runs +in the kagenti-operator (outside the workload pod). Verify the resulting +Secret is mounted into the agent's sidecar: ```bash -kubectl logs deployment/weather-service -n team1 -c kagenti-client-registration +kubectl get pod -n team1 -l app.kubernetes.io/name=weather-service \ + -o jsonpath='{.items[0].spec.volumes[?(@.secret)].secret.secretName}' +# Expect a Secret name starting with: kagenti-keycloak-client-credentials- ``` -Expected: +Inspect the actual SPIFFE-derived client ID written to /shared/client-id.txt: + +```bash +SIDECAR=$(kubectl get pod -n team1 -l app.kubernetes.io/name=weather-service \ + -o jsonpath='{.items[0].spec.containers[*].name}' | tr ' ' '\n' \ + | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) +kubectl exec deploy/weather-service -n team1 -c "$SIDECAR" -- cat /shared/client-id.txt +``` + +Expected — just the SPIFFE ID (the `Created Keycloak client …` log line +now lives in the kagenti-operator's `kagenti-controller-manager` +deployment in `kagenti-system`, not the workload pod): ``` -SPIFFE credentials ready! -Client ID (SPIFFE ID): spiffe://localtest.me/ns/team1/sa/weather-service -Created Keycloak client "spiffe://localtest.me/ns/team1/sa/weather-service" -Client registration complete! +spiffe://localtest.me/ns/team1/sa/weather-service +``` + +To follow the operator-side registration: + +```bash +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -i clientregistration | tail -20 ``` ### Check agent logs @@ -589,59 +623,79 @@ agent card while the agent container responds on port 8000, Envoy’s ext_proc p likely broken—often due to **invalid `authproxy-routes` YAML** left over from another workflow or namespace reuse. Follow **Agent card not available** in the [GitHub Issue Agent UI demo](../github-issue/demo-ui.md#agent-card-not-available-in-the-ui) -(check `envoy-proxy` logs and fix or remove `authproxy-routes` as described there). +(check the AuthBridge sidecar logs — `authbridge-proxy` in proxy-sidecar mode, +`envoy-proxy` in envoy-sidecar mode — and fix or remove `authproxy-routes` +as described there). -### Agent Pod Not Starting (4/4 containers) +### Agent Pod Not Starting -**Symptom:** Pod shows 3/4 or less containers ready +**Symptom:** Pod shows 1/2 (or 0/2) containers ready -**Fix:** Check each container's logs: +**Fix:** Check the agent and the AuthBridge sidecar: ```bash -kubectl logs deployment/weather-service -n team1 -c kagenti-client-registration -kubectl logs deployment/weather-service -n team1 -c spiffe-helper -kubectl logs deployment/weather-service -n team1 -c envoy-proxy +# AuthBridge sidecar — name depends on resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +kubectl logs deployment/weather-service -n team1 -c authbridge-proxy kubectl logs deployment/weather-service -n team1 -c agent + +# If the issue is operator-managed client registration not finishing, +# the workload pod waits on /shared/client-{id,secret}.txt. Inspect: +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|weather-service" | tail -20 ``` --- -## Proxy-Sidecar Mode +## Switching modes + +After kagenti-operator#361 the cluster default is **proxy-sidecar** +(forward + reverse HTTP proxies, no Envoy, no iptables, no init container). +The operator resolves mode per workload from this chain: -By default, the weather agent deploys in **envoy-sidecar mode** (Envoy + iptables). -You can switch to **proxy-sidecar mode** which uses a lightweight reverse/forward -proxy (29 MB image, no Envoy, no iptables). +1. `AgentRuntime.Spec.AuthBridgeMode` on the workload's CR (canonical). +2. `mode:` field on the namespace-level `authbridge-runtime-config` ConfigMap. +3. Deprecated `kagenti.io/authbridge-mode` pod annotation (still honored). +4. Cluster default — `proxy-sidecar`. -### Deploy in proxy-sidecar mode +### Switch to envoy-sidecar mode -Add the mode annotation to the deployment: +Set it on the AgentRuntime CR (canonical surface): + +```bash +kubectl patch agentruntime weather-service -n team1 --type=merge \ + -p '{"spec":{"authBridgeMode":"envoy-sidecar"}}' +kubectl rollout restart deployment weather-service -n team1 +kubectl rollout status deployment weather-service -n team1 --timeout=120s +``` + +Or, on workloads without an AgentRuntime CR, the deprecated annotation: ```bash kubectl patch deployment weather-service -n team1 --type=merge \ - -p '{"spec":{"template":{"metadata":{"annotations":{"kagenti.io/authbridge-mode":"proxy-sidecar"}}}}}' + -p '{"spec":{"template":{"metadata":{"annotations":{"kagenti.io/authbridge-mode":"envoy-sidecar"}}}}}' kubectl rollout status deployment weather-service -n team1 --timeout=120s ``` -### Verify proxy-sidecar mode +### Verify the resolved mode ```bash -# Containers: agent, authbridge-proxy, spiffe-helper, kagenti-client-registration -# No envoy-proxy or proxy-init kubectl get pod -n team1 -l app.kubernetes.io/name=weather-service \ -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{end}' - -# AuthBridge should show mode=proxy-sidecar -kubectl logs deployment/weather-service -n team1 -c authbridge-proxy --tail=5 +# Expect: +# proxy-sidecar (default): "agent" + "authbridge-proxy" +# envoy-sidecar: "agent" + "envoy-proxy" (plus a "proxy-init" init container) ``` -### Switch back to envoy-sidecar mode +### Switch back to proxy-sidecar (default) -Remove the annotation (envoy-sidecar is the default): +Drop the override: ```bash -kubectl patch deployment weather-service -n team1 --type=json \ - -p '[{"op":"remove","path":"/spec/template/metadata/annotations/kagenti.io~1authbridge-mode"}]' -kubectl rollout status deployment weather-service -n team1 --timeout=120s +kubectl patch agentruntime weather-service -n team1 --type=json \ + -p '[{"op":"remove","path":"/spec/authBridgeMode"}]' +kubectl rollout restart deployment weather-service -n team1 ``` ### Key differences diff --git a/authbridge/demos/weather-agent/k8s/configmaps-advanced.yaml b/authbridge/demos/weather-agent/k8s/configmaps-advanced.yaml index 13b04cd50..99bec3d0d 100644 --- a/authbridge/demos/weather-agent/k8s/configmaps-advanced.yaml +++ b/authbridge/demos/weather-agent/k8s/configmaps-advanced.yaml @@ -10,10 +10,10 @@ # go-processor. If your install overrides `authBridge.clientAuthType`, `keycloak.publicUrl`, # or similar, merge those values into this manifest before applying. # -# The advanced demo also adds explicit TOKEN_URL / JWKS_URL. Without TOKEN_URL, the ext_proc -# build used with envoy-with-processor can log "inbound JWT validation disabled" and allow -# unauthenticated requests to /mcp (HTTP 200). After applying, restart the weather -# deployment so pods pick up the new environment variables. +# The advanced demo also adds explicit TOKEN_URL / JWKS_URL. Without TOKEN_URL, +# the AuthBridge ext_proc / forward-proxy can log "inbound JWT validation +# disabled" and allow unauthenticated requests to /mcp (HTTP 200). After +# applying, restart the weather deployment so pods pick up the new env vars. # # The host value must match the HTTP Host header the agent uses when calling # the MCP service — use the short Kubernetes Service name from MCP_URL. diff --git a/authbridge/demos/webhook/README.md b/authbridge/demos/webhook/README.md index f806ad147..9f2e4f432 100644 --- a/authbridge/demos/webhook/README.md +++ b/authbridge/demos/webhook/README.md @@ -26,29 +26,24 @@ In both modes, Keycloak client registration is handled by the kagenti-operator's ## Architecture ``` -┌────────────────────────────────────────────────────────────────────┐ -│ Agent Pod │ -│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────────┐ │ -│ │ agent │ │spiffe-helper │ │kagenti-client-registration │ | -│ │ (your app) │ │ │ │ │ │ -│ └──────┬──────┘ └──────────────┘ └────────────────────────────┘ │ -│ │ │ -│ │ HTTP Request with Token (aud: agent-spiffe-id) │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ envoy-proxy │ │ -│ │ Inbound (port 15124): │ │ -│ │ 1. Intercepts incoming traffic (via iptables PREROUTING) │ │ -│ │ 2. Validates JWT (signature + issuer via JWKS) │ │ -│ │ 3. Returns 401 if invalid, forwards if valid │ │ -│ │ Outbound (port 15123): │ │ -│ │ 1. Intercepts outbound traffic (via iptables OUTPUT) │ │ -│ │ 2. Detects protocol via tls_inspector │ │ -│ │ HTTP: Extracts Bearer token, exchanges via Keycloak, │ │ -│ │ replaces token in request │ │ -│ │ HTTPS: Passes through as-is (TLS passthrough) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent Pod │ +│ ┌─────────────┐ ┌──────────────────────────────────────┐ │ +│ │ agent │ ───────▶│ AuthBridge sidecar │ │ +│ │ (your app) │ HTTP │ container name = mode-dependent: │ │ +│ └─────────────┘ with │ proxy-sidecar: authbridge-proxy │ │ +│ token │ envoy-sidecar: envoy-proxy │ │ +│ │ │ │ +│ │ Inbound: validates JWT, returns │ │ +│ │ 401 on invalid/missing │ │ +│ │ Outbound: routes via authproxy-routes;│ │ +│ │ HTTP → token-exchange via Keycloak;│ │ +│ │ HTTPS → TLS passthrough │ │ +│ │ │ │ +│ │ spiffe-helper bundled inside │ │ +│ │ (gated by SPIRE_ENABLED) │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ │ │ HTTP Request with Exchanged Token ▼ @@ -57,6 +52,11 @@ In both modes, Keycloak client registration is handled by the kagenti-operator's │ (validates aud: │ │ auth-target) │ └─────────────────┘ + +# Out-of-band: kagenti-operator's ClientRegistrationReconciler creates +# the Keycloak client + a kagenti-keycloak-client-credentials- +# Secret. The webhook mounts that Secret into the AuthBridge sidecar at +# /shared/client-{id,secret}.txt — no in-pod registration sidecar. ``` ## Prerequisites diff --git a/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml b/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml index 49d883365..e7d9f41f5 100644 --- a/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml +++ b/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml @@ -3,16 +3,19 @@ # This is a simplified version that doesn't require SPIRE. # The agent uses a static client ID instead of SPIFFE ID. # -# This deployment uses the kagenti-operator webhook to automatically inject: -# - proxy-init (init container for iptables setup) -# - kagenti-client-registration (registers with Keycloak using static client ID) -# - envoy-proxy (intercepts traffic and performs token exchange) +# This deployment uses the kagenti-operator webhook to automatically inject +# a single combined AuthBridge sidecar (post-#411). Without SPIRE the +# bundled spiffe-helper stays inactive (SPIRE_ENABLED=false); the operator +# still registers a Keycloak client using a static name and mounts the +# resulting Secret at /shared/client-{id,secret}.txt. Container shape: +# - proxy-sidecar (default): one container, "authbridge-proxy". +# - envoy-sidecar: "proxy-init" init container + "envoy-proxy" container. # # The agent container runs auth-proxy, which: # - Listens on port 8080 # - Forwards requests to auth-target-service:8081 -# - JWT validation is handled by the inbound ext-proc (injected by webhook) -# - Token exchange is handled by the outbound ext-proc (injected by webhook) +# - JWT validation is handled by the inbound AuthBridge sidecar (injected by webhook) +# - Token exchange is handled by the outbound AuthBridge sidecar (injected by webhook) # # Labels (must be on Pod template, not just Deployment): # kagenti.io/type: agent - Required: identifies this as an agent workload diff --git a/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml b/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml index 12a3f255e..f46358eb7 100644 --- a/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml +++ b/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml @@ -1,16 +1,21 @@ # Agent Deployment for AuthBridge Webhook Demo # -# This deployment uses the kagenti-operator webhook to automatically inject: -# - proxy-init (init container for iptables setup) -# - spiffe-helper (SPIFFE credential fetcher) - only with kagenti.io/spire: enabled -# - kagenti-client-registration (registers with Keycloak) -# - envoy-proxy (intercepts traffic and performs token exchange) +# This deployment uses the kagenti-operator webhook to automatically inject +# a single combined AuthBridge sidecar (post-#411). The exact shape depends +# on the resolved AuthBridge mode: +# - proxy-sidecar (default): one container, "authbridge-proxy", from the +# "authbridge" image. spiffe-helper is bundled inside; gated by SPIRE_ENABLED. +# - envoy-sidecar: a "proxy-init" init container (iptables) plus one +# container, "envoy-proxy", from the "authbridge-envoy" image (Envoy + +# ext_proc + bundled spiffe-helper). +# Keycloak client registration is operator-managed (no in-pod sidecar); +# the webhook mounts the resulting Secret at /shared/client-{id,secret}.txt. # # The agent container runs auth-proxy, which: # - Listens on port 8080 # - Forwards requests to auth-target-service:8081 -# - JWT validation is handled by the inbound ext-proc (injected by webhook) -# - Token exchange is handled by the outbound ext-proc (injected by webhook) +# - JWT validation is handled by the inbound AuthBridge sidecar (injected by webhook) +# - Token exchange is handled by the outbound AuthBridge sidecar (injected by webhook) # # Labels (must be on Pod template, not just Deployment): # kagenti.io/type: agent - Required: identifies this as an agent workload From b65c087a548222aa2d7a000dfc2e0491cbe02d50 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 17:29:16 -0400 Subject: [PATCH 5/9] docs(demos): consolidate single-target + multi-target into routes guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two broken-after-#411 standalone demos (`single-target/`, `multi-target/`) with a single configuration-only reference at `demos/token-exchange-routes/`. Both old demos used the pre-#411 multi-sidecar pattern (envoy-proxy + authbridge-light + client-registration + standalone spiffe-helper) with images that no longer publish; rewriting their YAMLs to the combined-sidecar shape is real per-demo work that needs runtime testing. The route-config content is the part that's still useful and isn't covered well by other demos, so extract that. New `demos/token-exchange-routes/README.md` covers: - How AuthBridge's outbound `token-exchange` plugin matches the request `Host` header against `authproxy-routes` entries and performs RFC 8693 exchange. - ConfigMap shape: per-route `host` / `target_audience` / `token_scopes` fields, with `kubectl create configmap` example. - Single-target example (one route entry, weather-tool style). - Multi-target example (three routes; the existing `routes.yaml` is moved into this directory unchanged as a worked example). - Mixing exchange and passthrough (default policy); tightening to `default_policy: exchange` for tool-only namespaces. - Glob pattern semantics — `filepath.Match` quirks around `**`, short-name matching, etc. - Troubleshooting: `Host` mismatches, missing scopes on the agent's own Keycloak client, audience errors at the target, routes-not-reloaded. - Cross-references to `weather-agent/demo-ui-advanced.md` and `webhook/README.md` for the deployment side. Removed: - `demos/single-target/` (entire directory) — covered by webhook + weather-agent demos plus the new routes reference; manual multi-sidecar deployment YAMLs were the broken bit. - `demos/multi-target/{demo.md,run-demo-commands.sh,teardown-demo.sh,k8s/}` — the script-driven flow tied to the dead `agent` Deployment in the `authbridge` namespace; not worth migrating. Renamed: - `demos/multi-target/` → `demos/token-exchange-routes/` (`git mv` preserves history on `routes.yaml`). Cross-references updated everywhere: - `demos/README.md` table: drop the strikethrough rows + warning paragraph; add Token-Exchange Routes / MCP Parser / abctl Walkthrough as Reference-tier entries; the recommended-path step 3 points at the new routes guide. - `demos/github-issue/{demo.md,demo-ui.md,demo-manual.md}`, `demos/weather-agent/demo-ui.md` — "Multi-Target Demo" links retargeted to `../token-exchange-routes/README.md` with updated link labels. - `authbridge/README.md` overview link bullet + the demos list at the bottom collapsed to one Token-Exchange Routes entry. - `authbridge/CLAUDE.md` — directory tree + demo descriptions + Keycloak setup-script table updated; gotcha #6 (hardcoded SPIFFE ID in `single-target/setup_keycloak.py`) removed since the file is gone, remaining gotchas renumbered. - `CLAUDE.md` (top-level) — directory listing line + manual- deployment learning-path bullet retargeted at the new routes guide. - `demos/webhook/setup_keycloak.py` — comment that pointed at `single-target/k8s/authbridge-deployment.yaml` for the `envoy-config` ConfigMap retargeted at the kagenti-system release namespace. Existing single/multi-target sweep banners from earlier in this PR (broken-after-#411 YAML headers) are deleted with the YAMLs. Net diff: -1,500 lines (the two broken demos), +210 lines (the new consolidated reference + cross-ref updates). Repo-wide grep for `single-target/` and `multi-target/` now matches only on prose that uses those words descriptively (e.g. "single-target route pattern"), not as path components. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 4 +- authbridge/CLAUDE.md | 38 +- authbridge/README.md | 5 +- authbridge/demos/README.md | 52 +- authbridge/demos/github-issue/demo-manual.md | 2 +- authbridge/demos/github-issue/demo-ui.md | 2 +- authbridge/demos/github-issue/demo.md | 2 +- authbridge/demos/multi-target/demo.md | 255 ------ .../k8s/authbridge-deployment-no-spiffe.yaml | 479 ------------ .../k8s/authbridge-deployment.yaml | 565 -------------- .../demos/multi-target/k8s/targets.yaml | 170 ---- .../demos/multi-target/run-demo-commands.sh | 71 -- .../demos/multi-target/teardown-demo.sh | 80 -- authbridge/demos/single-target/demo.md | 732 ------------------ .../k8s/authbridge-deployment-no-spiffe.yaml | 547 ------------- .../k8s/authbridge-deployment.yaml | 643 --------------- .../demos/single-target/setup_keycloak.py | 370 --------- .../demos/token-exchange-routes/README.md | 205 +++++ .../routes.yaml | 2 +- authbridge/demos/weather-agent/demo-ui.md | 2 +- authbridge/demos/webhook/setup_keycloak.py | 4 +- 21 files changed, 260 insertions(+), 3970 deletions(-) delete mode 100644 authbridge/demos/multi-target/demo.md delete mode 100644 authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml delete mode 100644 authbridge/demos/multi-target/k8s/authbridge-deployment.yaml delete mode 100644 authbridge/demos/multi-target/k8s/targets.yaml delete mode 100755 authbridge/demos/multi-target/run-demo-commands.sh delete mode 100755 authbridge/demos/multi-target/teardown-demo.sh delete mode 100644 authbridge/demos/single-target/demo.md delete mode 100644 authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml delete mode 100644 authbridge/demos/single-target/k8s/authbridge-deployment.yaml delete mode 100644 authbridge/demos/single-target/setup_keycloak.py create mode 100644 authbridge/demos/token-exchange-routes/README.md rename authbridge/demos/{multi-target => token-exchange-routes}/routes.yaml (93%) diff --git a/CLAUDE.md b/CLAUDE.md index c4af590cf..c850e1739 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ kagenti-extensions/ │ ├── authproxy/ # iptables init container + standalone quickstart │ │ ├── quickstart/ # Standalone demo (no SPIFFE) │ │ └── k8s/ # Standalone K8s manifests -│ ├── demos/ # Demo scenarios (weather-agent, github-issue, webhook, single-target, multi-target) +│ ├── demos/ # Demo scenarios (weather-agent, github-issue, webhook, token-exchange-routes, mcp-parser) │ └── keycloak_sync.py # Declarative Keycloak sync tool ├── tests/ # Python tests (keycloak_sync) ├── .github/ @@ -242,7 +242,7 @@ cd authbridge/authproxy && make build-images - **Getting started**: `authbridge/demos/weather-agent/demo-ui.md` (inbound validation, UI deployment) - **Full flow**: `authbridge/demos/github-issue/demo-ui.md` (token exchange + scope-based access) - **Webhook internals**: `authbridge/demos/webhook/README.md` - - **Manual deployment**: `authbridge/demos/single-target/demo.md` + - **Routes config reference**: `authbridge/demos/token-exchange-routes/README.md` (single + multi-target route patterns) ### Adding a New Component Image to CI diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index c1c312f1a..6f100702a 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -73,13 +73,12 @@ authbridge/ ├── demos/ # Demo scenarios with full setup │ ├── README.md # Demo index (recommended starting order) │ ├── weather-agent/ # Getting-started demo (inbound validation only) -│ │ └── demo-ui.md -│ ├── single-target/ # Single agent → target (SPIFFE-based) -│ │ ├── demo.md -│ │ ├── setup_keycloak.py -│ │ └── k8s/ -│ ├── multi-target/ # Multi-target with keycloak_sync -│ │ └── k8s/ +│ │ ├── demo-ui.md +│ │ ├── demo-ui-advanced.md # With token exchange + tool-side AuthBridge +│ │ └── demo-with-abctl.md # Plugin-pipeline TUI walkthrough +│ ├── token-exchange-routes/ # Routes config reference (single + multi-target) +│ │ ├── README.md +│ │ └── routes.yaml │ ├── github-issue/ # GitHub integration demo │ │ ├── demo.md, demo-ui.md, demo-manual.md │ │ ├── setup_keycloak.py @@ -192,22 +191,21 @@ Envoy config lives in `demos/webhook/k8s/configmaps-webhook.yaml` (the `envoy-co ## Demo Scenarios -The `demos/` directory contains five demonstration scenarios (see `demos/README.md` for a recommended learning path): +The `demos/` directory contains the following scenarios (see `demos/README.md` for a recommended learning path): -- **weather-agent/** -- Getting-started demo: inbound JWT validation with outbound passthrough. Simplest way to see AuthBridge in action (UI deployment). +- **weather-agent/** -- Getting-started demo: inbound JWT validation with outbound passthrough. Simplest way to see AuthBridge in action (UI deployment). `demo-ui-advanced.md` extends this with outbound token exchange and tool-side AuthBridge; `demo-with-abctl.md` is a plugin-pipeline tooling walkthrough. - **webhook/** -- Shows how to use the webhook (now part of [kagenti-operator](https://github.com/kagenti/kagenti-operator)) to automatically inject AuthBridge sidecars. Recommended starting point for webhook-based deployments. -- **single-target/** -- Manual deployment demo showing agent → target communication with SPIFFE identity and token exchange. -- **multi-target/** -- Dynamic scope assignment using `keycloak_sync.py` for agents communicating with multiple targets. - **github-issue/** -- External API integration (GitHub) with inbound validation, outbound token exchange, and scope-based access control. Available as UI or manual deployment. +- **token-exchange-routes/** -- Configuration reference for the `authproxy-routes` ConfigMap. Covers single-target (one route) and multi-target (one agent → many tools) patterns. Pairs with one of the deployment demos for a full stack. +- **mcp-parser/** -- Configuration reference for enabling the outbound `mcp-parser` plugin. ## Keycloak Setup Scripts -There are **four** setup scripts for different demo scenarios: +There are **three** setup scripts for different demo scenarios: | Script | Location | Use Case | |--------|----------|----------| | `setup_keycloak.py` | `authbridge/demos/webhook/` | Webhook-injected deployments (parameterized namespace/SA, creates realm, auth-target client, agent-spiffe-aud + auth-target-aud scopes, alice user) | -| `setup_keycloak.py` | `authbridge/demos/single-target/` | Single-target SPIFFE demo (creates realm, auth-target client, agent-spiffe-aud + auth-target-aud scopes, alice user) | | `setup_keycloak.py` | `authbridge/demos/github-issue/` | GitHub issue integration demo (creates github-tool client, github-tool-aud + github-full-access scopes, alice + bob users) | | `setup_keycloak.py` | `authbridge/authproxy/quickstart/` | Standalone AuthProxy quickstart without SPIFFE (creates application-caller, authproxy, demoapp clients with per-client scope assignment) | @@ -478,19 +476,17 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h 5. **Virtualenv directory**: For local development you may create `authproxy/quickstart/venv/`, but it should be gitignored and is not committed to the repo. -6. **Demo SPIFFE ID is hardcoded**: `demos/single-target/setup_keycloak.py` hardcodes `AGENT_SPIFFE_ID = "spiffe://localtest.me/ns/authbridge/sa/agent"`. Change this if using a different namespace/SA. - -7. **Admin credentials in ConfigMap**: `demos/webhook/k8s/configmaps-webhook.yaml` stores Keycloak admin credentials in a ConfigMap (not a Secret). This is for demo only -- production should use Kubernetes Secrets. +6. **Admin credentials in ConfigMap**: `demos/webhook/k8s/configmaps-webhook.yaml` stores Keycloak admin credentials in a ConfigMap (not a Secret). This is for demo only -- production should use Kubernetes Secrets. -8. **Envoy Lua filter required for inbound**: The `x-authbridge-direction: inbound` header MUST be injected via a Lua filter before the ext_proc filter in the inbound listener. Route-level `request_headers_to_add` does NOT work because the router filter runs after ext_proc. +7. **Envoy Lua filter required for inbound**: The `x-authbridge-direction: inbound` header MUST be injected via a Lua filter before the ext_proc filter in the inbound listener. Route-level `request_headers_to_add` does NOT work because the router filter runs after ext_proc. -9. **iptables backend auto-detection**: `init-iptables.sh` auto-detects `iptables-legacy` vs `iptables-nft`. Override with `IPTABLES_CMD` env var if needed. Always verify with proxy-init logs after deployment. +8. **iptables backend auto-detection**: `init-iptables.sh` auto-detects `iptables-legacy` vs `iptables-nft`. Override with `IPTABLES_CMD` env var if needed. Always verify with proxy-init logs after deployment. -10. **Route host patterns must match HTTP Host header**: The `host` field in `authproxy-routes` is matched against the HTTP `Host` header, which is set by the HTTP client from the URL hostname. For in-cluster calls, this is the **short Kubernetes service name** from `MCP_URL` (e.g., `github-tool-mcp`), not the FQDN. Using the wrong pattern (e.g., `*.github-issue-tool*.svc.cluster.local`) will silently fall through to the default passthrough policy. +9. **Route host patterns must match HTTP Host header**: The `host` field in `authproxy-routes` is matched against the HTTP `Host` header, which is set by the HTTP client from the URL hostname. For in-cluster calls, this is the **short Kubernetes service name** from `MCP_URL` (e.g., `github-tool-mcp`), not the FQDN. Using the wrong pattern (e.g., `*.github-issue-tool*.svc.cluster.local`) will silently fall through to the default passthrough policy. -11. **Keycloak scope assignment for dynamically registered clients**: When `client-registration` auto-registers an agent as a Keycloak client, the client may not inherit all necessary scopes. The agent's own audience scope (e.g., `agent-team1-git-issue-agent-aud`) must be a **default** client scope for inbound JWT audience validation to work. Token exchange scopes (e.g., `github-tool-aud`, `github-full-access`) must be **optional** client scopes for `client_credentials` grants with explicit `scope=` to succeed. Re-run the demo's `setup_keycloak.py` after the agent is deployed to assign these scopes to the registered client. +10. **Keycloak scope assignment for dynamically registered clients**: When `client-registration` auto-registers an agent as a Keycloak client, the client may not inherit all necessary scopes. The agent's own audience scope (e.g., `agent-team1-git-issue-agent-aud`) must be a **default** client scope for inbound JWT audience validation to work. Token exchange scopes (e.g., `github-tool-aud`, `github-full-access`) must be **optional** client scopes for `client_credentials` grants with explicit `scope=` to succeed. Re-run the demo's `setup_keycloak.py` after the agent is deployed to assign these scopes to the registered client. -12. **Outbound passthrough is the safe default**: The `DEFAULT_OUTBOUND_POLICY` defaults to `passthrough`, which means outbound traffic to LLM inference endpoints (e.g., Ollama via `host.docker.internal`) passes through without token exchange. If this were set to `exchange`, all outbound HTTP calls would attempt token exchange and fail for non-Keycloak destinations. +11. **Outbound passthrough is the safe default**: The `DEFAULT_OUTBOUND_POLICY` defaults to `passthrough`, which means outbound traffic to LLM inference endpoints (e.g., Ollama via `host.docker.internal`) passes through without token exchange. If this were set to `exchange`, all outbound HTTP calls would attempt token exchange and fail for non-Keycloak destinations. ## DCO Sign-Off (Mandatory) diff --git a/authbridge/README.md b/authbridge/README.md index dc39e56c7..023d97d8b 100644 --- a/authbridge/README.md +++ b/authbridge/README.md @@ -2,7 +2,7 @@ AuthBridge provides **secure, transparent token management** for Kubernetes workloads. It combines automatic [client registration](./client-registration/) with [token exchange](./authproxy/) capabilities, enabling zero-trust authentication flows with [SPIFFE/SPIRE](https://spiffe.io) integration. -> **📘 Looking to run the demo?** See the [Single-Target Demo](./demos/single-target/demo.md) or [Multi-Target Demo](./demos/multi-target/demo.md) for step-by-step instructions. +> **📘 Looking to run the demo?** See the [Weather Agent](./demos/weather-agent/demo-ui.md) or [Webhook](./demos/webhook/README.md) demos for step-by-step instructions, and [Token-Exchange Routes](./demos/token-exchange-routes/README.md) for route configuration. ## Deployment Modes @@ -349,8 +349,7 @@ The easiest way to get all prerequisites is to use the [Kagenti Ansible installe - **[GitHub Issue Agent Demo](./demos/github-issue/demo.md)** - End-to-end demo with the real GitHub Issue Agent and GitHub MCP Tool, showing transparent token exchange via AuthBridge - [Manual deployment](./demos/github-issue/demo-manual.md) — deploy everything via `kubectl` and YAML manifests - [UI deployment](./demos/github-issue/demo-ui.md) — import agent and tool via the Kagenti dashboard -- **[Single-Target Demo](./demos/single-target/demo.md)** - Basic token exchange to one target service (manual deployment) -- **[Multi-Target Demo](./demos/multi-target/demo.md)** - Route-based token exchange to multiple targets +- **[Token-Exchange Routes](./demos/token-exchange-routes/README.md)** - Configuration reference for the `authproxy-routes` ConfigMap; covers single-target (one route) and multi-target (one agent → many tools) patterns All demos cover configuring Keycloak, deploying, and testing. diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index ffc034222..d3eb49428 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -19,17 +19,9 @@ more AuthBridge capabilities. | **[Weather Agent (advanced)](weather-agent/demo-ui-advanced.md)** | Intermediate | Inbound on agent **and** tool, outbound token exchange, ingress JWT verification on the tool | [kubectl + script](weather-agent/demo-ui-advanced.md#automated-deploy-and-verify-ci-oriented) | | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | | **[Webhook](webhook/README.md)** | Intermediate | Webhook-based sidecar injection with auth-target demo app | Manual | -| ~~**[Single Target](single-target/demo.md)**~~ ⚠️ **(broken — see below)** | Advanced | Manual AuthBridge deployment (no webhook) with SPIFFE identity | Manual | -| ~~**[Multi-Target](multi-target/demo.md)**~~ ⚠️ **(broken — see below)** | Advanced | Route-based token exchange to multiple target services | Manual | - -> **⚠️ Single Target / Multi-Target demos are currently broken** after -> kagenti-extensions#411. The YAMLs use the pre-#411 multi-sidecar pattern -> (separate `envoy-proxy`, `authbridge-light`, `client-registration`, and -> `spiffe-helper` containers) with images that no longer publish — applying -> them yields ImagePullBackOff. They need migration to the combined sidecar -> shape (one `authbridge` / `authbridge-envoy` container) before they're -> usable again. Use the **Webhook** or **Weather Agent** demos in the -> meantime. +| **[Token-Exchange Routes](token-exchange-routes/README.md)** | Reference | How to write `authproxy-routes` for single- and multi-target token exchange | Configuration only | +| **[MCP Parser Plugin](mcp-parser/README.md)** | Reference | Enable the `mcp-parser` plugin to surface tool calls / resource reads in session events | Configuration only | +| **[abctl Walkthrough](weather-agent/demo-with-abctl.md)** | Reference | Watch the AuthBridge plugin pipeline live with the `abctl` TUI | Tooling only | ## Recommended Path @@ -45,9 +37,11 @@ more AuthBridge capabilities. transparently exchanges tokens when the agent calls the GitHub tool, with scope-based access control (Alice vs Bob). -3. **[Multi-Target](multi-target/demo.md)** — Advanced routing with per-host - token exchange configuration. Shows how a single agent can communicate with - multiple target services, each requiring different audience tokens. +3. **[Token-Exchange Routes](token-exchange-routes/README.md)** — Reference + for the `authproxy-routes` ConfigMap. Covers both single-target (one + route) and multi-target (one agent → multiple tools, each with its own + audience) patterns. Configuration-only — pair with one of the deployment + demos above for a working stack. ## What Each Demo Covers @@ -82,17 +76,25 @@ more AuthBridge capabilities. - Tests inbound validation and outbound token exchange end-to-end - Good for understanding the injection labels and ConfigMap requirements -### Single Target -- Manual deployment without the webhook (all sidecars in the YAML) -- SPIFFE-based identity with SPIRE -- Single agent → single target with token exchange -- Good for understanding AuthBridge internals - -### Multi-Target -- Route-based token exchange using `authproxy-routes` ConfigMap -- One agent communicating with multiple target services -- Each target gets a token with the correct audience -- Uses `keycloak_sync.py` for declarative scope management +### Token-Exchange Routes (Configuration Reference) +- How AuthBridge resolves the request `Host` header to a route entry +- ConfigMap shape for `authproxy-routes` — fields, glob patterns, ordering +- Single-target example (one route) +- Multi-target example (multiple routes, one agent → multiple tools) +- Mixing exchange and passthrough; tightening to `default_policy: exchange` +- Troubleshooting: `Host` mismatches, missing scopes, audience errors + +### MCP Parser Plugin (Configuration Reference) +- How to enable the `mcp-parser` outbound plugin +- Surfaces MCP tool calls / resource reads / prompt invocations in + session events for `abctl` and the `:9094` API +- Required `allow_mode_override: true` on the outbound ext_proc filter + in envoy-sidecar mode + +### abctl Walkthrough (Tooling Reference) +- Run the `abctl` TUI against the weather-agent's session API +- See inbound JWT validation → protocol parsers → outbound exchange + → LLM inference → response, paired live ## Prerequisites diff --git a/authbridge/demos/github-issue/demo-manual.md b/authbridge/demos/github-issue/demo-manual.md index 05dcf69fa..f444c9aa2 100644 --- a/authbridge/demos/github-issue/demo-manual.md +++ b/authbridge/demos/github-issue/demo-manual.md @@ -1149,7 +1149,7 @@ kubectl delete mutatingwebhookconfiguration kagenti-webhook-authbridge-mutating- - **UI Deployment**: See [demo-ui.md](demo-ui.md) for deploying via the Kagenti dashboard - **AuthBridge Binary**: See the [AuthBridge README](../../cmd/authbridge/README.md) for inbound JWT validation and outbound token exchange internals -- **Multi-Target Demo**: See the [multi-target demo](../multi-target/demo.md) for +- **Token-Exchange Routes**: See the [routes-configuration guide](../token-exchange-routes/README.md) for route-based token exchange to multiple tool services - **Access Policies**: See the [access policies proposal](../../PROPOSAL-access-policies.md) for role-based delegation control diff --git a/authbridge/demos/github-issue/demo-ui.md b/authbridge/demos/github-issue/demo-ui.md index c29200659..116b194a8 100644 --- a/authbridge/demos/github-issue/demo-ui.md +++ b/authbridge/demos/github-issue/demo-ui.md @@ -1204,6 +1204,6 @@ kubectl delete namespace team1 - **Manual Deployment**: See [demo-manual.md](demo-manual.md) for deploying everything via `kubectl` - **AuthBridge Binary**: See the [AuthBridge README](../../cmd/authbridge/README.md) for inbound JWT validation and outbound token exchange internals -- **Multi-Target Demo**: See the [multi-target demo](../multi-target/demo.md) for +- **Token-Exchange Routes**: See the [routes-configuration guide](../token-exchange-routes/README.md) for route-based token exchange to multiple tool services - **AuthBridge Overview**: See the [AuthBridge README](../../README.md) for architecture details diff --git a/authbridge/demos/github-issue/demo.md b/authbridge/demos/github-issue/demo.md index 06235f72f..8e29e69a0 100644 --- a/authbridge/demos/github-issue/demo.md +++ b/authbridge/demos/github-issue/demo.md @@ -78,6 +78,6 @@ Common names used by both: - [All Demos](../README.md) — index of all AuthBridge demos - [Weather Agent Demo](../weather-agent/demo-ui.md) — simpler getting-started demo (no token exchange) -- [Multi-Target Demo](../multi-target/demo.md) — route-based token exchange to multiple tools +- [Token-Exchange Routes](../token-exchange-routes/README.md) — route-based token exchange to multiple tools - [Access Policies Proposal](../../PROPOSAL-access-policies.md) — role-based delegation control - [AuthBridge Overview](../../README.md) — architecture and design diff --git a/authbridge/demos/multi-target/demo.md b/authbridge/demos/multi-target/demo.md deleted file mode 100644 index 475474b64..000000000 --- a/authbridge/demos/multi-target/demo.md +++ /dev/null @@ -1,255 +0,0 @@ -# Multi-Target Demo - -> **⚠️ This demo is currently broken after kagenti-extensions#411.** The -> manifests in `k8s/` use the pre-#411 multi-sidecar pattern with images -> that no longer publish (`authbridge-unified`, `client-registration`, -> standalone `spiffe-helper`). Applying them yields ImagePullBackOff. -> The build instructions below also reference Dockerfile paths that no -> longer exist (`cmd/authbridge/Dockerfile`). -> -> The demo concept (route-based token exchange to multiple targets) -> still applies, but the YAMLs need migration to the combined sidecar -> shape (one `authbridge` / `authbridge-envoy` container per pod). Use -> `authbridge/demos/weather-agent/demo-ui-advanced.md` or -> `authbridge/demos/webhook/README.md` in the meantime. - -This demo shows AuthBridge performing route-based token exchange to multiple target services. - -## Overview - -``` -Agent Pod Target Pods -+------------------+ +------------------+ -| | | target-alpha | -| agent | token exchange | aud: target-alpha -| |------------------------>+------------------+ -| | | -| AuthProxy | | +------------------+ -| sidecar |---------|-------------->| target-beta | -| | | | aud: target-beta | -+------------------+ | +------------------+ - | - | +------------------+ - +-------------->| target-gamma | - | aud: target-gamma - +------------------+ -``` - -The AuthProxy automatically exchanges tokens based on the destination host: - -| Host Pattern | Target Audience | Scope | -|--------------|-----------------|-------| -| `target-alpha-service**` | `target-alpha` | `target-alpha-aud` | -| `target-beta-service**` | `target-beta` | `target-beta-aud` | -| `target-gamma-service**` | `target-gamma` | `target-gamma-aud` | - -## Prerequisites - -- Kubernetes cluster with SPIRE installed -- Keycloak running in the cluster -- AuthBridge images built and loaded - -## Setup - -### 1. Build Images - -Build the AuthProxy images (includes the ext-proc with route-based exchange): - -```bash -cd authproxy - -# Build all images -make build-images - -# Load into Kind (default cluster name is "kagenti") -make load-images - -# Or specify a different cluster name -make load-images KIND_CLUSTER_NAME= -``` - -This builds: -- `demo-app` - Target service that validates JWT audience -- `auth-proxy` - Auth proxy container -- `proxy-init` - iptables init container - -Build the `authbridge-unified` sidecar image separately (from `authbridge/` context): - -```bash -cd authbridge && podman build -f cmd/authbridge/Dockerfile -t authbridge-unified:latest . -kind load docker-image authbridge-unified:latest --name kagenti -``` - -### 2. Sync Routes with Keycloak - -**Important:** This step must be done before deploying pods, as the client-registration -init container requires the realm to exist. - -Port-forward Keycloak: - -```bash -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -Use `keycloak_sync.py` to reconcile the routes configuration with Keycloak: - -```bash -cd authbridge - -# Create virtual environment (if not already done) -python -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -# Dry run first to see what would be created -python keycloak_sync.py --config demos/multi-target/routes.yaml --dry-run - -# Apply changes (interactive prompts) -# Use --agent-client to pre-create the agent and assign scopes to it -python keycloak_sync.py --config demos/multi-target/routes.yaml \ - --agent-client "spiffe://localtest.me/ns/authbridge/sa/agent" - -# Or auto-approve all changes -python keycloak_sync.py --config demos/multi-target/routes.yaml \ - --agent-client "spiffe://localtest.me/ns/authbridge/sa/agent" --yes -``` - -This reconciles routes.yaml with Keycloak, creating: -- The `kagenti` realm (if it doesn't exist) -- The agent client (if `--agent-client` is specified and it doesn't exist) -- `target-alpha`, `target-beta`, `target-gamma` clients (targets) -- Audience scopes (`target-alpha-aud`, etc.) with audience mappers -- Hostname attributes on each target client -- Assigns scopes to the agent client (so it can request tokens for each audience) - -### 3. Deploy the Demo - -Deploy everything (agent, targets, routes): - -```bash -# With SPIFFE (requires SPIRE) -kubectl apply -f demos/multi-target/k8s/authbridge-deployment.yaml -kubectl apply -f demos/multi-target/k8s/targets.yaml - -# OR without SPIFFE -kubectl apply -f demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml -kubectl apply -f demos/multi-target/k8s/targets.yaml -``` - -This deploys: -- Agent pod with AuthProxy sidecar -- Routes ConfigMap with multi-target configuration -- Three target service pods (alpha, beta, gamma) - -### 4. Wait for Pods - -```bash -kubectl wait --for=condition=available --timeout=180s deployment/agent -n authbridge -kubectl wait --for=condition=available --timeout=120s deployment/target-alpha -n authbridge -kubectl wait --for=condition=available --timeout=120s deployment/target-beta -n authbridge -kubectl wait --for=condition=available --timeout=120s deployment/target-gamma -n authbridge -``` - -## Test the Flow - -Run the demo script to see token exchange in action: - -```bash -./demos/multi-target/run-demo-commands.sh -``` - -Expected output: - -``` -=== Original Token (before exchange) === - Client ID: spiffe://localtest.me/ns/authbridge/sa/agent - Audience: (none - client credentials token) - Scopes: profile email - -=== After Token Exchange (for target-alpha) === - Audience: target-alpha - Scopes: openid profile target-alpha-aud email - -=== Calling All Targets (AuthBridge exchanges automatically) === - -Target Alpha: -authorized -Target Beta: -authorized -Target Gamma: -authorized - -=== AuthBridge Token Exchange Logs === -[Resolver] Host "target-alpha-service" matched "target-alpha-service**" -[Resolver] Using route target_audience: target-alpha -[Token Exchange] Successfully exchanged token, replacing Authorization header -[Resolver] Host "target-beta-service" matched "target-beta-service**" -[Resolver] Using route target_audience: target-beta -[Token Exchange] Successfully exchanged token, replacing Authorization header -[Resolver] Host "target-gamma-service" matched "target-gamma-service**" -[Resolver] Using route target_audience: target-gamma -[Token Exchange] Successfully exchanged token, replacing Authorization header -``` - -The output shows: -1. **Original token** has no audience and basic scopes (`profile email`) -2. **After exchange** the token has `target-alpha` as audience and includes `target-alpha-aud` scope -3. **All targets** return "authorized" because AuthBridge exchanges the token automatically -4. **Logs** show each host being matched and the token exchange succeeding - -## How It Works - -1. Agent obtains a token from Keycloak using client credentials (no specific audience) -2. Agent makes HTTP request to a target service with this token -3. Envoy intercepts the request and sends headers to the ext_proc (authbridge) -4. ext_proc resolves the destination host against `routes.yaml` configuration -5. ext_proc performs OAuth 2.0 Token Exchange (RFC 8693) to get a new token with: - - The target's audience (e.g., `target-alpha`) - - The target's required scopes (e.g., `openid target-alpha-aud`) -6. Envoy forwards the request with the exchanged token -7. Target validates the token audience and returns "authorized" - -## Routes Configuration - -The `routes.yaml` file maps hosts to token exchange parameters. Glob patterns are used to match -both short hostnames and FQDNs: - -```yaml -# Target Alpha - matches both short name and FQDN -- host: "target-alpha-service**" - target_audience: "target-alpha" - token_scopes: "openid target-alpha-aud" - -# Target Beta - matches both short name and FQDN -- host: "target-beta-service**" - target_audience: "target-beta" - token_scopes: "openid target-beta-aud" - -# Target Gamma - matches both short name and FQDN -- host: "target-gamma-service**" - target_audience: "target-gamma" - token_scopes: "openid target-gamma-aud" -``` - -## Cleanup - -Use the teardown script to delete k8s resources and the Keycloak realm: - -```bash -./demos/multi-target/teardown-demo.sh -``` - -The script uses `http://keycloak.localtest.me:8080` by default. Override with `KEYCLOAK_URL` if needed. - -Or manually: - -```bash -kubectl delete -f demos/multi-target/k8s/authbridge-deployment.yaml -kubectl delete -f demos/multi-target/k8s/targets.yaml -``` - -To delete the entire namespace: - -```bash -kubectl delete namespace authbridge -``` diff --git a/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml b/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml deleted file mode 100644 index aee70b9e2..000000000 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment-no-spiffe.yaml +++ /dev/null @@ -1,479 +0,0 @@ -# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the -# single-target authbridge-deployment.yaml. See its header for the -# dead images. Applying this YAML today will produce ImagePullBackOff. -# -# 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. -# -# Flow: Caller -> AuthProxy (sidecar, exchanges token) -> Auth Target (validates token) -# -# Pod 1: Caller Pod -# - Caller: The calling application -# - Client Registration (init): Registers caller with Keycloak using static client ID -# - AuthProxy + Envoy: Intercepts outgoing traffic and exchanges tokens -# -# Pod 2: Auth Target (Target Server) -# - Validates tokens with audience "auth-target" - ---- -# ============================================================================= -# NAMESPACE AND SERVICE ACCOUNT -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: authbridge - labels: - istio.io/dataplane-mode: ambient - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: authbridge - ---- -# ============================================================================= -# AUTH PROXY CONFIGURATION -# CLIENT_ID and CLIENT_SECRET come from /shared/ files (populated by client-registration) -# ============================================================================= -apiVersion: v1 -kind: Secret -metadata: - name: auth-proxy-config - namespace: authbridge -type: Opaque -stringData: - TOKEN_URL: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/token" - ---- -# ============================================================================= -# CONFIGMAPS -# ============================================================================= -# Shared ConfigMap for environment variables -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: authbridge -data: - SPIRE_ENABLED: "false" - KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "kagenti" - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "admin" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" - ---- -# Envoy configuration for AuthProxy -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - namespace: authbridge -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - # External processor to mutate headers (token exchange) - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: go_processor - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SEND - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: go_processor - type: STATIC - http2_protocol_options: {} - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: go_processor - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 - - - name: original_destination - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED - ---- -# Routes configuration for per-host token exchange settings -apiVersion: v1 -kind: ConfigMap -metadata: - name: authproxy-routes - namespace: authbridge -data: - routes.yaml: | - # Multi-Target Demo Routes Configuration - # Each route maps a destination host to token exchange parameters. - # Order matters - first match wins. - - # Target Alpha - matches both short name and FQDN - - host: "target-alpha-service**" - target_audience: "target-alpha" - token_scopes: "openid target-alpha-aud" - - # Target Beta - matches both short name and FQDN - - host: "target-beta-service**" - target_audience: "target-beta" - token_scopes: "openid target-beta-aud" - - # Target Gamma - matches both short name and FQDN - - host: "target-gamma-service**" - target_audience: "target-gamma" - token_scopes: "openid target-gamma-aud" - ---- -# ============================================================================= -# CALLER POD (No SPIFFE) -# Contains: Caller, Client Registration, AuthProxy -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: caller - namespace: authbridge - labels: - app: caller -spec: - replicas: 1 - selector: - matchLabels: - app: caller - template: - metadata: - labels: - app: caller - # istio.io/dataplane-mode: none - annotations: - # ambient.istio.io/redirection: disabled - sidecar.istio.io/inject: "false" - spec: - serviceAccountName: caller - imagePullSecrets: - - name: ghcr-secret - initContainers: - # Init container to set up iptables for traffic interception - - name: proxy-init - image: proxy-init:latest - imagePullPolicy: IfNotPresent - securityContext: - privileged: false - capabilities: - add: - - NET_ADMIN - - NET_RAW - drop: - - ALL - runAsNonRoot: false - runAsUser: 0 - env: - - name: PROXY_PORT - value: "15123" - - name: PROXY_UID - value: "1337" - - name: OUTBOUND_PORTS_EXCLUDE - value: "8080" # Exclude Keycloak port from iptables redirect - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - resources: - limits: - cpu: 10m - memory: 10Mi - requests: - cpu: 10m - memory: 10Mi - # Client registration - registers caller with Keycloak using static client ID - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - | - # Save client ID to file (same as CLIENT_NAME when SPIRE is disabled) - echo "caller" > /shared/client-id.txt - echo "Starting client registration..." - python client_registration.py - echo "Client registration complete!" - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: KEYCLOAK_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_URL - - name: KEYCLOAK_REALM - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_REALM - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_PASSWORD - - name: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - - name: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - - name: CLIENT_NAME - value: caller - - name: SECRET_FILE_PATH - value: /shared/client-secret.txt - volumeMounts: - - name: shared-data - mountPath: /shared - containers: - # Caller - the calling application (uses netshoot with curl and jq) - - name: caller - image: nicolaka/netshoot:latest - command: - - /bin/sh - - -c - - | - echo "Caller pod ready!" - echo "Waiting for client registration to complete..." - 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!" - echo "Client ID: $CLIENT_ID" - echo "Client Secret: $CLIENT_SECRET" - echo "============================================" - echo "" - echo "To test, exec into this container and run:" - echo "" - echo " CLIENT_ID=\$(cat /shared/client-id.txt)" - echo " CLIENT_SECRET=\$(cat /shared/client-secret.txt)" - echo " TOKEN=\$(curl -sX POST http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\" - echo " -d 'grant_type=client_credentials' \\" - echo " -d \"client_id=\$CLIENT_ID\" \\" - echo " -d \"client_secret=\$CLIENT_SECRET\" | jq -r '.access_token')" - echo "" - echo " curl -H \"Authorization: Bearer \$TOKEN\" http://auth-target-service:8081/test" - echo "" - tail -f /dev/null - volumeMounts: - - name: shared-data - mountPath: /shared - # AuthProxy - validates and prepares for token exchange - - name: auth-proxy - image: auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: PORT - value: "8080" - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - # Transparent mode: no AUDIENCE validation - # AuthProxy accepts any valid token (validates signature + issuer only) - # and exchanges it for auth-target audience - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - # Envoy proxy - intercepts outgoing traffic and performs token exchange - - name: envoy-proxy - image: authbridge-unified:latest - imagePullPolicy: IfNotPresent - securityContext: - runAsUser: 1337 - runAsGroup: 1337 - ports: - - containerPort: 15123 - name: envoy-outbound - - containerPort: 9901 - name: envoy-admin - - containerPort: 9090 - name: ext-proc - env: - # Token exchange configuration - - name: TOKEN_URL - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: TOKEN_URL - # Dynamic credentials from client-registration (via files) - - name: CLIENT_ID_FILE - value: "/shared/client-id.txt" - - name: CLIENT_SECRET_FILE - value: "/shared/client-secret.txt" - # Route-based configuration - - name: ROUTES_CONFIG_PATH - value: "/etc/authproxy/routes.yaml" - volumeMounts: - - name: envoy-config - mountPath: /etc/envoy - readOnly: true - - name: shared-data - mountPath: /shared - readOnly: true - - name: routes-config - mountPath: /etc/authproxy - readOnly: true - resources: - limits: - cpu: 200m - memory: 256Mi - requests: - cpu: 50m - memory: 64Mi - volumes: - - name: shared-data - emptyDir: {} - - name: envoy-config - configMap: - name: envoy-config - - name: routes-config - configMap: - name: authproxy-routes - ---- -# ============================================================================= -# AUTH TARGET POD (Target Server) -# Validates tokens with audience "auth-target" -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-target - namespace: authbridge - labels: - app: auth-target -spec: - replicas: 1 - selector: - matchLabels: - app: auth-target - template: - metadata: - labels: - app: auth-target - spec: - containers: - - name: auth-target - image: demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "auth-target" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - ---- -# Auth Target Service -apiVersion: v1 -kind: Service -metadata: - name: auth-target-service - namespace: authbridge - labels: - app: auth-target -spec: - type: ClusterIP - ports: - - port: 8081 - targetPort: 8081 - protocol: TCP - name: http - selector: - app: auth-target diff --git a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml b/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml deleted file mode 100644 index cd158aef4..000000000 --- a/authbridge/demos/multi-target/k8s/authbridge-deployment.yaml +++ /dev/null @@ -1,565 +0,0 @@ -# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the -# single-target authbridge-deployment.yaml. See its header for the -# dead images. Applying this YAML today will produce ImagePullBackOff. -# -# 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 -# 3. Auth Target (target server) validates the exchanged tokens -# -# Flow: -# 0. SPIFFE Helper obtains SVID (Agent's identity = SPIFFE ID) -# 1. Client Registration registers Agent with Keycloak -# 2. Caller gets token with aud: Agent's SPIFFE ID -# 3. Caller passes token to Agent -# 4. Agent calls Auth Target with Caller's token -# 5. AuthProxy intercepts, validates, and exchanges token (using Agent's credentials) -# 6. Auth Target validates exchanged token (aud: auth-target) -# -# Pod 1: Agent Pod -# - Agent: The agent application (receives tokens from Callers) -# - SPIFFE Helper: Provides SPIFFE credentials (SVID) -# - Client Registration: Registers Agent with Keycloak using SPIFFE ID -# - AuthProxy + Envoy: Intercepts outgoing traffic and exchanges tokens -# -# Pod 2: Auth Target (Target Server) -# - Validates tokens with audience "auth-target" - ---- -# ============================================================================= -# NAMESPACE AND SERVICE ACCOUNT -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: authbridge - labels: - istio.io/dataplane-mode: ambient - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: authbridge - ---- -# ============================================================================= -# AUTH PROXY CONFIGURATION -# CLIENT_ID and CLIENT_SECRET come from /shared/ files (populated by client-registration) -# ============================================================================= -apiVersion: v1 -kind: Secret -metadata: - name: auth-proxy-config - namespace: authbridge -type: Opaque -stringData: - TOKEN_URL: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/token" - ---- -# ============================================================================= -# CONFIGMAPS -# ============================================================================= -# Shared ConfigMap for environment variables -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: authbridge -data: - SPIRE_ENABLED: "true" - KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "kagenti" - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "admin" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" - ---- -# SPIFFE Helper configuration for the caller pod -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: authbridge -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - svid_file_name = "/opt/svid.pem" - svid_key_file_name = "/opt/svid_key.pem" - svid_bundle_file_name = "/opt/svid_bundle.pem" - jwt_svids = [{jwt_audience="kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] - jwt_svid_file_mode = 0644 - include_federated_domains = true - ---- -# Envoy configuration for AuthProxy -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - namespace: authbridge -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - # External processor to mutate headers (token exchange) - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: go_processor - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SEND - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: go_processor - type: STATIC - http2_protocol_options: {} - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: go_processor - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 - - - name: original_destination - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED - ---- -# Routes configuration for per-host token exchange settings -apiVersion: v1 -kind: ConfigMap -metadata: - name: authproxy-routes - namespace: authbridge -data: - routes.yaml: | - # Multi-Target Demo Routes Configuration - # Each route maps a destination host to token exchange parameters. - # Order matters - first match wins. - - # Target Alpha - matches both short name and FQDN - - host: "target-alpha-service**" - target_audience: "target-alpha" - token_scopes: "openid target-alpha-aud" - - # Target Beta - matches both short name and FQDN - - host: "target-beta-service**" - target_audience: "target-beta" - token_scopes: "openid target-beta-aud" - - # Target Gamma - matches both short name and FQDN - - host: "target-gamma-service**" - target_audience: "target-gamma" - token_scopes: "openid target-gamma-aud" - ---- -# ============================================================================= -# AGENT POD -# Contains: Agent, SPIFFE Helper, Client Registration, AuthProxy -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent - namespace: authbridge - labels: - app: agent -spec: - replicas: 1 - selector: - matchLabels: - app: agent - template: - metadata: - labels: - app: agent - # istio.io/dataplane-mode: none - annotations: - # ambient.istio.io/redirection: disabled - sidecar.istio.io/inject: "false" - spec: - serviceAccountName: agent - imagePullSecrets: - - name: ghcr-secret - initContainers: - # Init container to set up iptables for traffic interception - - name: proxy-init - image: proxy-init:latest - imagePullPolicy: IfNotPresent - securityContext: - privileged: false - capabilities: - add: - - NET_ADMIN - - NET_RAW - drop: - - ALL - runAsNonRoot: false - runAsUser: 0 - env: - - name: PROXY_PORT - value: "15123" - - name: PROXY_UID - value: "1337" - - name: OUTBOUND_PORTS_EXCLUDE - value: "8080" # Exclude Keycloak port from iptables redirect - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - resources: - limits: - cpu: 10m - memory: 10Mi - requests: - cpu: 10m - memory: 10Mi - containers: - # Client registration - registers caller with Keycloak using SPIFFE ID - # Runs as a container (not init) because it needs SPIFFE Helper to be running - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - | - echo "Waiting for SPIFFE credentials..." - while [ ! -f /opt/jwt_svid.token ]; do - echo "Waiting for SVID..." - 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) - # Add padding if needed and decode - 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!" - echo "Staying alive to keep pod running..." - tail -f /dev/null - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: KEYCLOAK_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_URL - - name: KEYCLOAK_REALM - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_REALM - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_PASSWORD - - name: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - - name: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - - name: CLIENT_NAME - value: caller - - name: SECRET_FILE_PATH - value: /shared/client-secret.txt - volumeMounts: - - name: shared-data - mountPath: /shared - - name: svid-output - mountPath: /opt - # Agent - the agent application (receives tokens from Callers, calls external services) - # Use: kubectl exec -it deployment/agent -n authbridge -c agent -- sh - # Then: curl -H "Authorization: Bearer $TOKEN" http://auth-target-service:8081/test - - name: agent - image: nicolaka/netshoot:latest - command: - - /bin/sh - - -c - - | - echo "Agent pod ready!" - echo "Waiting for client registration to complete..." - 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!" - echo "Client ID (Agent's SPIFFE ID): $CLIENT_ID" - echo "Client Secret: $CLIENT_SECRET" - echo "============================================" - echo "" - echo "=== AuthBridge Flow ===" - echo "1. Caller gets token with aud: $CLIENT_ID (this Agent's identity)" - echo "2. Caller passes token to Agent" - echo "3. Agent calls Auth Target with Caller's token" - echo "4. AuthProxy exchanges token for aud: auth-target" - echo "" - echo "To test (simulating a Caller providing a token):" - echo "" - echo " # Get a token (as if Caller obtained it)" - echo " CLIENT_ID=\$(cat /shared/client-id.txt)" - echo " CLIENT_SECRET=\$(cat /shared/client-secret.txt)" - echo " TOKEN=\$(curl -sX POST http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\" - echo " -d 'grant_type=client_credentials' \\" - echo " -d \"client_id=\$CLIENT_ID\" \\" - echo " -d \"client_secret=\$CLIENT_SECRET\" | jq -r '.access_token')" - echo "" - echo " # Verify token audience (should be Agent's SPIFFE ID)" - echo " echo \$TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp}'" - echo "" - echo " # Agent calls auth-target (AuthProxy will exchange token)" - echo " curl -H \"Authorization: Bearer \$TOKEN\" http://auth-target-service:8081/test" - echo "" - # Keep container running - tail -f /dev/null - volumeMounts: - - name: shared-data - mountPath: /shared - # SPIFFE Helper - provides SPIFFE credentials (SVID) - - name: spiffe-helper - image: ghcr.io/spiffe/spiffe-helper:nightly - command: - - /spiffe-helper - - -config=/etc/spiffe-helper/helper.conf - - run - volumeMounts: - - name: spiffe-helper-config - mountPath: /etc/spiffe-helper - - name: spire-agent-socket - mountPath: /spiffe-workload-api - - name: svid-output - mountPath: /opt - # AuthProxy - validates and prepares for token exchange - - name: auth-proxy - image: auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: PORT - value: "8080" - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - # Transparent mode: no AUDIENCE validation - # AuthProxy accepts any valid token (validates signature + issuer only) - # and exchanges it for auth-target audience - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - # Envoy proxy - intercepts outgoing traffic and performs token exchange - # Uses dynamic credentials from /shared/ (populated by client-registration) - # The SPIFFE ID is both the client_id AND the token audience, enabling exchange - - name: envoy-proxy - image: authbridge-unified:latest - imagePullPolicy: IfNotPresent - securityContext: - runAsUser: 1337 - runAsGroup: 1337 - ports: - - containerPort: 15123 - name: envoy-outbound - - containerPort: 9901 - name: envoy-admin - - containerPort: 9090 - name: ext-proc - env: - # Token exchange configuration - - name: TOKEN_URL - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: TOKEN_URL - # Dynamic credentials from client-registration (via files) - - name: CLIENT_ID_FILE - value: "/shared/client-id.txt" - - name: CLIENT_SECRET_FILE - value: "/shared/client-secret.txt" - # Route-based configuration - - name: ROUTES_CONFIG_PATH - value: "/etc/authproxy/routes.yaml" - volumeMounts: - - name: envoy-config - mountPath: /etc/envoy - readOnly: true - - name: shared-data - mountPath: /shared - readOnly: true - - name: routes-config - mountPath: /etc/authproxy - readOnly: true - resources: - limits: - cpu: 200m - memory: 256Mi - requests: - cpu: 50m - memory: 64Mi - volumes: - - name: shared-data - emptyDir: {} - - name: spiffe-helper-config - configMap: - name: spiffe-helper-config - - name: spire-agent-socket - hostPath: - path: /run/spire/agent-sockets - - name: svid-output - emptyDir: {} - - name: envoy-config - configMap: - name: envoy-config - - name: routes-config - configMap: - name: authproxy-routes - ---- -# ============================================================================= -# AUTH TARGET POD (Target Server) -# Validates tokens with audience "auth-target" -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-target - namespace: authbridge - labels: - app: auth-target -spec: - replicas: 1 - selector: - matchLabels: - app: auth-target - template: - metadata: - labels: - app: auth-target - spec: - containers: - - name: auth-target - image: demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - # Auth Target expects tokens with "auth-target" audience - - name: AUDIENCE - value: "auth-target" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - ---- -# Auth Target Service -apiVersion: v1 -kind: Service -metadata: - name: auth-target-service - namespace: authbridge - labels: - app: auth-target -spec: - type: ClusterIP - ports: - - port: 8081 - targetPort: 8081 - protocol: TCP - name: http - selector: - app: auth-target diff --git a/authbridge/demos/multi-target/k8s/targets.yaml b/authbridge/demos/multi-target/k8s/targets.yaml deleted file mode 100644 index 1654e7e15..000000000 --- a/authbridge/demos/multi-target/k8s/targets.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# Multi-Target Demo - Target Services -# -# Three target services that validate tokens with different audiences. -# Each reuses the demo-app image with a different AUDIENCE env var. ---- -apiVersion: v1 -kind: Namespace -metadata: - name: authbridge - labels: - istio.io/dataplane-mode: ambient ---- -# Target Alpha -apiVersion: apps/v1 -kind: Deployment -metadata: - name: target-alpha - namespace: authbridge - labels: - app: target-alpha -spec: - replicas: 1 - selector: - matchLabels: - app: target-alpha - template: - metadata: - labels: - app: target-alpha - spec: - containers: - - name: target-alpha - image: demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "target-alpha" - resources: - requests: - memory: "64Mi" - cpu: "100m" - limits: - memory: "128Mi" - cpu: "250m" ---- -apiVersion: v1 -kind: Service -metadata: - name: target-alpha-service - namespace: authbridge -spec: - selector: - app: target-alpha - ports: - - protocol: TCP - port: 8081 - targetPort: 8081 - type: ClusterIP ---- -# Target Beta -apiVersion: apps/v1 -kind: Deployment -metadata: - name: target-beta - namespace: authbridge - labels: - app: target-beta -spec: - replicas: 1 - selector: - matchLabels: - app: target-beta - template: - metadata: - labels: - app: target-beta - spec: - containers: - - name: target-beta - image: demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "target-beta" - resources: - requests: - memory: "64Mi" - cpu: "100m" - limits: - memory: "128Mi" - cpu: "250m" ---- -apiVersion: v1 -kind: Service -metadata: - name: target-beta-service - namespace: authbridge -spec: - selector: - app: target-beta - ports: - - protocol: TCP - port: 8081 - targetPort: 8081 - type: ClusterIP ---- -# Target Gamma -apiVersion: apps/v1 -kind: Deployment -metadata: - name: target-gamma - namespace: authbridge - labels: - app: target-gamma -spec: - replicas: 1 - selector: - matchLabels: - app: target-gamma - template: - metadata: - labels: - app: target-gamma - spec: - containers: - - name: target-gamma - image: demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "target-gamma" - resources: - requests: - memory: "64Mi" - cpu: "100m" - limits: - memory: "128Mi" - cpu: "250m" ---- -apiVersion: v1 -kind: Service -metadata: - name: target-gamma-service - namespace: authbridge -spec: - selector: - app: target-gamma - ports: - - protocol: TCP - port: 8081 - targetPort: 8081 - type: ClusterIP diff --git a/authbridge/demos/multi-target/run-demo-commands.sh b/authbridge/demos/multi-target/run-demo-commands.sh deleted file mode 100755 index 17c424be2..000000000 --- a/authbridge/demos/multi-target/run-demo-commands.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -## -# Run the commands outlined after initial setup and deployment in `demo.md` -# - -set -eu - - - -## Step 1. Get Token and Show Exchange - -kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' -CLIENT_ID=$(cat /shared/client-id.txt) -CLIENT_SECRET=$(cat /shared/client-secret.txt) -TOKEN_URL="http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" - -# Get original token response (includes scope in response) -ORIG_RESP=$(curl -s $TOKEN_URL \ - -d "grant_type=client_credentials" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET") - -TOKEN=$(echo "$ORIG_RESP" | jq -r ".access_token") -ORIG_SCOPE=$(echo "$ORIG_RESP" | jq -r ".scope") - -echo "=== Original Token (before exchange) ===" -echo " Client ID: $CLIENT_ID" -echo " Audience: (none - client credentials token)" -echo " Scopes: $ORIG_SCOPE" -echo "" - -# Manually exchange for target-alpha to show the transformation -EXCHANGE_RESP=$(curl -s $TOKEN_URL \ - -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" \ - -d "subject_token=$TOKEN" \ - -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ - -d "audience=target-alpha" \ - -d "scope=openid target-alpha-aud") - -EXCHANGED_SCOPE=$(echo "$EXCHANGE_RESP" | jq -r ".scope") - -echo "=== After Token Exchange (for target-alpha) ===" -echo " Audience: target-alpha" -echo " Scopes: $EXCHANGED_SCOPE" -echo "" - -echo "=== Calling All Targets (AuthBridge exchanges automatically) ===" -echo "" - -echo "Target Alpha:" -curl -s -H "Authorization: Bearer $TOKEN" http://target-alpha-service:8081/test -echo "" - -echo "Target Beta:" -curl -s -H "Authorization: Bearer $TOKEN" http://target-beta-service:8081/test -echo "" - -echo "Target Gamma:" -curl -s -H "Authorization: Bearer $TOKEN" http://target-gamma-service:8081/test -echo "" -' - - -## Step 2. Check AuthBridge Logs -echo "" -echo "=== AuthBridge Token Exchange Logs ===" -kubectl logs deployment/agent -n authbridge -c envoy-proxy --tail=500 2>&1 | \ - grep -E "Host.*matched|Using route target_audience|Successfully exchanged token," | \ - tail -12 diff --git a/authbridge/demos/multi-target/teardown-demo.sh b/authbridge/demos/multi-target/teardown-demo.sh deleted file mode 100755 index af9cdaf08..000000000 --- a/authbridge/demos/multi-target/teardown-demo.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# -# Teardown script for the multi-target demo. -# Deletes k8s resources and optionally the Keycloak realm. -# - -set -eu - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -K8S_DIR="${SCRIPT_DIR}/k8s" - -KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak.localtest.me:8080}" -KEYCLOAK_ADMIN_USER="${KEYCLOAK_ADMIN_USER:-admin}" -KEYCLOAK_ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}" -REALM="${REALM:-kagenti}" - -echo "=== Multi-Target Demo Teardown ===" -echo "" - -# Delete k8s resources -echo "Deleting Kubernetes resources..." -kubectl delete -f "${K8S_DIR}/targets.yaml" --ignore-not-found=true 2>/dev/null || true -kubectl delete -f "${K8S_DIR}/authbridge-deployment.yaml" --ignore-not-found=true 2>/dev/null || true -kubectl delete -f "${K8S_DIR}/authbridge-deployment-no-spiffe.yaml" --ignore-not-found=true 2>/dev/null || true -echo "Kubernetes resources deleted." -echo "" - -# Delete Keycloak realm (with safety check for platform realm) -if [ "${REALM}" = "kagenti" ] && [ "${FORCE_REALM_DELETE:-}" != "true" ]; then - echo "WARNING: The 'kagenti' realm is the platform's default realm." - echo "Deleting it will break authentication for all platform services." - echo "" - echo "To delete demo-specific Keycloak objects only, use keycloak_sync.py --delete." - echo "To force realm deletion, set FORCE_REALM_DELETE=true." - echo "" - echo "Skipping realm deletion. Kubernetes resources were deleted above." - exit 0 -fi - -echo "Deleting Keycloak realm '${REALM}' from ${KEYCLOAK_URL}..." -echo "" - -# Get admin token -TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ - -d "grant_type=password" \ - -d "client_id=admin-cli" \ - -d "username=${KEYCLOAK_ADMIN_USER}" \ - -d "password=${KEYCLOAK_ADMIN_PASSWORD}" 2>/dev/null) || { - echo "Warning: Could not connect to Keycloak at ${KEYCLOAK_URL}" - echo "Make sure Keycloak is port-forwarded and try again." - exit 0 -} - -ACCESS_TOKEN=$(echo "${TOKEN_RESPONSE}" | jq -r '.access_token' 2>/dev/null) - -if [ "${ACCESS_TOKEN}" = "null" ] || [ -z "${ACCESS_TOKEN}" ]; then - echo "Warning: Could not authenticate to Keycloak." - echo "Response: ${TOKEN_RESPONSE}" - exit 1 -fi - -# Delete the realm -DELETE_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ - "${KEYCLOAK_URL}/admin/realms/${REALM}" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}") - -case "${DELETE_RESPONSE}" in - 204) - echo "Realm '${REALM}' deleted successfully." - ;; - 404) - echo "Realm '${REALM}' does not exist (already deleted)." - ;; - *) - echo "Warning: Unexpected response when deleting realm: HTTP ${DELETE_RESPONSE}" - ;; -esac - -echo "" -echo "=== Teardown Complete ===" diff --git a/authbridge/demos/single-target/demo.md b/authbridge/demos/single-target/demo.md deleted file mode 100644 index 737f86941..000000000 --- a/authbridge/demos/single-target/demo.md +++ /dev/null @@ -1,732 +0,0 @@ -# AuthBridge Demo Guide - -> **⚠️ This demo is currently broken after kagenti-extensions#411.** The -> manifests in `k8s/` use the pre-#411 multi-sidecar pattern with images -> that no longer publish (`authbridge-unified`, `client-registration`, -> standalone `spiffe-helper`). Applying them yields ImagePullBackOff. -> The demo needs migration to the combined sidecar shape (one -> `authbridge` / `authbridge-envoy` container per pod). Use -> `authbridge/demos/weather-agent/demo-ui-advanced.md` or -> `authbridge/demos/webhook/README.md` in the meantime. - -This guide provides step-by-step instructions for running the AuthBridge demo. - -> **📘 New to AuthBridge?** See the [AuthBridge README](../../README.md) for an overview of what AuthBridge does and how it works. - -## Demo Components - -The demo deploys the following components: - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ KUBERNETES CLUSTER │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ AGENT POD (namespace: authbridge) │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │ │ -│ │ │ agent │ │ spiffe- │ │ client-registration │ │ │ -│ │ │ (netshoot) │ │ helper │ │ (registers with Keycloak) │ │ │ -│ │ └─────────────┘ └─────────────┘ └──────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────────┐ │ │ -│ │ │ AuthProxy Sidecar │ │ │ -│ │ │ ┌────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │ -│ │ │ │ auth-proxy │ │ envoy-proxy │ │ ext-proc │ │ │ │ -│ │ │ │ (8080) │ │ (15123 out) │ │ (inbound: JWT valid.) │ │ │ │ -│ │ │ │ │ │ (15124 in) │ │ (outbound: token xch) │ │ │ │ -│ │ │ └────────────┘ └──────────────┘ └────────────────────────┘ │ │ │ -│ │ └───────────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ AUTH-TARGET POD (namespace: authbridge) │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ -│ │ │ auth-target (8081) │ │ │ -│ │ │ Validates tokens with aud: auth-target │ │ │ -│ │ └─────────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ EXTERNAL SERVICES │ -│ │ -│ ┌──────────────────────┐ ┌──────────────────────┐ │ -│ │ SPIRE (namespace: │ │ KEYCLOAK (namespace: │ │ -│ │ spire) │ │ keycloak) │ │ -│ │ │ │ │ │ -│ │ Provides SPIFFE │ │ - kagenti realm │ │ -│ │ identities (SVIDs) │ │ - token exchange │ │ -│ └──────────────────────┘ └──────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -
-📊 Mermaid Component Diagram (click to expand) - -```mermaid -flowchart TB - subgraph Cluster["KUBERNETES CLUSTER"] - subgraph AgentPod["AGENT POD
(namespace: authbridge)"] - agent["agent
(netshoot)"] - spiffe["spiffe-helper"] - clientreg["client-registration"] - subgraph Sidecar["AuthProxy Sidecar"] - authproxy["auth-proxy
:8080"] - envoy["envoy-proxy
:15123 (out) :15124 (in)"] - 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 - agent --> envoy - envoy --> extproc - extproc --> keycloak - envoy --> target - - style AgentPod fill:#e3f2fd - style TargetPod fill:#e8f5e9 - style Sidecar fill:#fff3e0 - style External fill:#fce4ec -``` - -
- -## Demo Flow - -The following diagram shows the complete token flow from initialization through request handling: - -``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ INITIALIZATION PHASE │ -└──────────────────────────────────────────────────────────────────────────────┘ - - SPIRE Agent Agent Pod Keycloak - │ │ │ - │ 1. Issue SVID │ │ - │ ──────────────────►│ spiffe-helper │ - │ (SPIFFE ID) │ │ - │ │ │ - │ │ 2. Register client │ - │ │ (client_id = SPIFFE ID) │ - │ │ ────────────────────────────────────►│ - │ │ client-registration │ - │ │ │ - │ │◄─────────────────────────────────────│ - │ │ client_secret │ - │ │ (saved to /shared/) │ - -┌──────────────────────────────────────────────────────────────────────────────┐ -│ RUNTIME PHASE │ -└──────────────────────────────────────────────────────────────────────────────┘ - - Agent Container AuthProxy Sidecar Keycloak Auth-Target - │ │ │ │ - │ 3. Get token │ │ │ - │ (client_credentials)│ │ │ - │────────────────────────┼───────────────────►│ │ - │◄───────────────────────┼────────────────────│ │ - │ Token: aud=SPIFFE ID│ │ │ - │ │ │ │ - │ 4. Call auth-target │ │ │ - │ with token │ │ │ - │───────────────────────►│ │ │ - │ │ │ │ - │ ┌────┴────┐ │ │ - │ │ Envoy │ │ │ - │ │inbound │ │ │ - │ │intercept│ │ │ - │ └────┬────┘ │ │ - │ │ │ │ - │ │ 4b. Validate JWT │ │ - │ │ (ext-proc) │ │ - │ │ ✓ signature │ │ - │ │ ✓ issuer │ │ - │ │ │ │ - │ ┌────┴────┐ │ │ - │ │ Envoy │ │ │ - │ │outbound │ │ │ - │ │intercept│ │ │ - │ └────┬────┘ │ │ - │ │ │ │ - │ │ 5. Token Exchange │ │ - │ │ (ext-proc) │ │ - │ │───────────────────►│ │ - │ │◄───────────────────│ │ - │ │ New token: │ │ - │ │ aud=auth-target │ │ - │ │ │ │ - │ │ 6. Forward request │ │ - │ │ with new token │ │ - │ │────────────────────────────────────────►│ - │ │ │ │ - │ │◄────────────────────────────────────────│ - │ │ "authorized" │ - │◄───────────────────────│ │ │ - │ Response │ │ │ -``` - -
-📊 Mermaid Sequence Diagram (click to expand) - -```mermaid -sequenceDiagram - autonumber - participant SPIRE as SPIRE Agent - participant Helper as spiffe-helper - participant Reg as client-registration - participant Agent as agent container - participant Envoy as envoy-proxy - participant ExtProc as ext-proc - participant KC as Keycloak - participant Target as auth-target - - rect rgb(240, 248, 255) - Note over SPIRE,Target: INITIALIZATION PHASE - SPIRE->>Helper: Issue SVID (SPIFFE ID) - Helper->>Reg: SPIFFE credentials ready - Reg->>KC: Register client (client_id = SPIFFE ID) - KC-->>Reg: client_secret (saved to /shared/) - end - - rect rgb(255, 248, 240) - 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" - Envoy-->>Agent: Response - end -``` - -
- -### What Gets Verified - -| Step | Component | Action | Verification | -|------|-----------|--------|--------------| -| 1 | SPIRE → spiffe-helper | Issue SVID | Pod receives cryptographic identity (SPIFFE ID) | -| 2 | setup_keycloak.py | Configure realm | Creates `kagenti` realm, `auth-target` client, scopes, and demo user `alice` | -| 3 | client-registration → Keycloak | Register client | Keycloak client created with `client_id = SPIFFE ID` | -| 4 | agent → Keycloak | Get token | Token issued with `aud: SPIFFE ID`, `scope: agent-spiffe-aud` | -| 5 | agent → envoy-proxy | HTTP request | Envoy intercepts inbound traffic, Ext Proc validates JWT (signature + issuer) | -| 5b | ext-proc | Inbound validation | JWT signature validated via JWKS, issuer checked against `ISSUER` env var, 401 returned if invalid | -| 6 | ext-proc → Keycloak | Token Exchange | Outbound: token exchanged: `aud: SPIFFE ID` → `aud: auth-target` | -| 7 | envoy-proxy → auth-target | Forward request | Request sent with exchanged token | -| 7b | Subject preservation | User token exchange | `sub` and `preferred_username` preserved through exchange | -| 8 | auth-target | Validate token | Token validated (`aud: auth-target`), returns `"authorized"` | - -### Key Security Properties - -| Property | How It's Achieved | -|----------|-------------------| -| **No hardcoded secrets** | Client credentials generated dynamically by client-registration | -| **Identity-based auth** | SPIFFE ID serves as both pod identity and Keycloak client ID | -| **Audience scoping** | First token is scoped to Agent; exchanged token is scoped to auth-target | -| **Transparent to app** | Agent code just makes HTTP calls; AuthProxy handles token exchange | -| **Audit trail** | `azp` claim shows which client (SPIFFE ID) performed the exchange | - ---- - -## End-to-End Testing Guide - -### Step 1: Build and Load AuthProxy Images - -*Note: This step will be replaced by the CI pipeline. The images will be auto-created* - -```bash -cd authbridge/authproxy - -# Build all images -make build-images - -# Load images into Kind cluster -make load-images -``` - -### Step 2: Configure Keycloak - -Assuming Keycloak is running as a part of the Kagenti install, port-forward Keycloak to access it locally: - -```bash -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -In a new terminal, run the setup script: - -```bash -cd authbridge - -# Create virtual environment -python -m venv venv -source venv/bin/activate - -# Install dependencies -pip install --upgrade pip -pip install -r requirements.txt - -# Run setup script -python demos/single-target/setup_keycloak.py -``` - -The `setup_keycloak` script creates: - -- `kagenti` realm -- `auth-target` client (token exchange target audience) -- `agent-spiffe-aud` scope (realm default - adds Agent's SPIFFE ID to all tokens) -- `auth-target-aud` scope (for exchanged tokens) -- `alice` demo user (username: `alice`, password: `alice123`) for testing user token flows - -**Note:** No static `agent` client is created - the AuthProxy uses the dynamically -registered client credentials from `/shared/` (populated by client-registration). - -### Step 3: Configure GitHub Image Pull Secret (if needed) - -If using Kagenti, copy the ghcr secret: - -```bash -kubectl get secret ghcr-secret -n team1 -o yaml | sed 's/namespace: team1/namespace: authbridge/' | kubectl apply -f - -``` - -### Step 4: Deploy the Demo - -```bash -cd authbridge - -# With SPIFFE (requires SPIRE) -kubectl apply -f demos/single-target/k8s/authbridge-deployment.yaml -``` - -OR without SPIFFE: - -```bash -kubectl apply -f demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml -``` - -This creates: - -- `authbridge` namespace -- `agent` ServiceAccount -- ConfigMaps and Secrets (including `auth-proxy-config`) -- `agent` and `auth-target` deployments - -### Step 5: Wait for Deployments - -```bash -kubectl wait --for=condition=available --timeout=180s deployment/agent -n authbridge -kubectl wait --for=condition=available --timeout=120s deployment/auth-target -n authbridge -``` - -### Step 6: Test the Flow - -These tests verify both **inbound** JWT validation and **outbound** token exchange end-to-end. By sending requests from outside the agent pod, each request exercises the full pipeline: - -1. **Inbound**: Envoy intercepts the incoming request, ext-proc validates the JWT (signature + issuer) -2. **Outbound**: auth-proxy forwards to auth-target, Envoy intercepts the outgoing request, ext-proc exchanges the token - -#### Setup - -```bash -# Get the agent pod IP (requests must come from outside the pod to hit inbound validation) -AGENT_POD_IP=$(kubectl get pod -l app=agent -n authbridge -o jsonpath='{.items[0].status.podIP}') - -# Get a service account token -TOKEN=$(kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' - CLIENT_ID=$(cat /shared/client-id.txt) - CLIENT_SECRET=$(cat /shared/client-secret.txt) - curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=client_credentials" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" | jq -r ".access_token" -') - -# Get a user token for alice (for subject preservation test) -USER_TOKEN=$(kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' - CLIENT_ID=$(cat /shared/client-id.txt) - CLIENT_SECRET=$(cat /shared/client-secret.txt) - curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=password" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" \ - -d "username=alice" -d "password=alice123" | jq -r ".access_token" -') - -# Start a test client pod (sends requests from outside the agent pod) -kubectl run test-client --image=nicolaka/netshoot -n authbridge --restart=Never -- sleep 3600 -kubectl wait --for=condition=ready pod/test-client -n authbridge --timeout=30s -``` - -#### 6a. Inbound Rejection - No Token - -```bash -kubectl exec test-client -n authbridge -- curl -s http://$AGENT_POD_IP:8080/test -# Expected: {"error":"unauthorized","message":"missing Authorization header"} -``` - -#### 6b. Inbound Rejection - Invalid Token - -```bash -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer invalid-token" http://$AGENT_POD_IP:8080/test -# Expected: {"error":"unauthorized","message":"token validation failed: ..."} -``` - -#### 6c. End-to-End with Service Account Token - -Inbound validation passes, outbound token exchange converts `aud: ` → `aud: auth-target`: - -```bash -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer $TOKEN" http://$AGENT_POD_IP:8080/test -# Expected: "authorized" -``` - -#### 6d. End-to-End with User Token (Subject Preservation) - -Same as 6c, but using alice's user token. The `sub` and `preferred_username` claims are preserved through token exchange: - -```bash -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer $USER_TOKEN" http://$AGENT_POD_IP:8080/test -# Expected: "authorized" -``` - -**Key Difference (6c vs 6d):** Compare the `sub` and `preferred_username` claims: -- Service account token: `sub` is a service account ID, `preferred_username` is like `service-account-spiffe://...` -- User token: `sub` is alice's user ID, `preferred_username` is `alice` - -#### Clean Up - -```bash -kubectl delete pod test-client -n authbridge --ignore-not-found -``` - -#### Quick Test Commands - -Run all tests as a single script: - -```bash -AGENT_POD_IP=$(kubectl get pod -l app=agent -n authbridge -o jsonpath='{.items[0].status.podIP}') -kubectl run test-client --image=nicolaka/netshoot -n authbridge --restart=Never -- sleep 3600 2>/dev/null -kubectl wait --for=condition=ready pod/test-client -n authbridge --timeout=30s - -TOKEN=$(kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' - CLIENT_ID=$(cat /shared/client-id.txt); CLIENT_SECRET=$(cat /shared/client-secret.txt) - curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=client_credentials" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" | jq -r ".access_token" -') - -USER_TOKEN=$(kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' - CLIENT_ID=$(cat /shared/client-id.txt); CLIENT_SECRET=$(cat /shared/client-secret.txt) - curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=password" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" \ - -d "username=alice" -d "password=alice123" | jq -r ".access_token" -') - -echo "=== 6a. No Token (expect 401) ===" -kubectl exec test-client -n authbridge -- curl -s http://$AGENT_POD_IP:8080/test -echo "" - -echo "=== 6b. Invalid Token (expect 401) ===" -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer invalid-token" http://$AGENT_POD_IP:8080/test -echo "" - -echo "=== 6c. Service Account Token (expect authorized) ===" -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer $TOKEN" http://$AGENT_POD_IP:8080/test -echo "" - -echo "=== 6d. User Token - alice (expect authorized) ===" -kubectl exec test-client -n authbridge -- curl -s -H "Authorization: Bearer $USER_TOKEN" http://$AGENT_POD_IP:8080/test -echo "" - -kubectl delete pod test-client -n authbridge --ignore-not-found -``` - -### Step 7: Inspect Token Claims (Before and After Exchange) - -This step shows how the token claims change during exchange for **both** service account and user tokens. - -#### 7a. Service Account Token - Before and After - -```bash -kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' -CLIENT_ID=$(cat /shared/client-id.txt) -CLIENT_SECRET=$(cat /shared/client-secret.txt) - -TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=client_credentials" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" | jq -r ".access_token") - -echo "=== SERVICE ACCOUNT TOKEN (Before Exchange) ===" -echo $TOKEN | cut -d"." -f2 | tr "_-" "/+" | { read p; echo "${p}=="; } | base64 -d | jq "{aud, azp, iss, sub, preferred_username, scope}" - -echo "" -echo "Calling auth-target... (token exchange happens)" -curl -s -H "Authorization: Bearer $TOKEN" http://auth-target-service:8081/test -echo "" -' -``` - -Then check auth-target logs for the **exchanged** token: - -```bash -echo "=== SERVICE ACCOUNT TOKEN (After Exchange) ===" && \ -kubectl logs deployment/auth-target -n authbridge --tail=20 | grep -E "(Subject|Audience|Authorized Party|Preferred)" || echo "Check logs manually" -``` - -#### 7b. User Token (alice) - Before and After - -```bash -kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' -CLIENT_ID=$(cat /shared/client-id.txt) -CLIENT_SECRET=$(cat /shared/client-secret.txt) - -USER_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \ - -d "grant_type=password" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" \ - -d "username=alice" \ - -d "password=alice123" | jq -r ".access_token") - -echo "=== USER TOKEN - alice (Before Exchange) ===" -echo $USER_TOKEN | cut -d"." -f2 | tr "_-" "/+" | { read p; echo "${p}=="; } | base64 -d | jq "{aud, azp, iss, sub, preferred_username, scope}" - -echo "" -echo "Calling auth-target with alice token... (token exchange happens)" -curl -s -H "Authorization: Bearer $USER_TOKEN" http://auth-target-service:8081/test -echo "" -' -``` - -Then check auth-target logs for the **exchanged** token (note `sub` and `preferred_username` are preserved!): - -```bash -echo "=== USER TOKEN - alice (After Exchange) ===" && \ -kubectl logs deployment/auth-target -n authbridge --tail=10 | grep -E "(Subject|Audience|Authorized Party|Preferred)" || echo "Check logs manually" -``` - -#### Token Claims Comparison - -| Claim | Service Account (Before) | Service Account (After) | User Token (Before) | User Token (After) | -|-------|--------------------------|-------------------------|---------------------|-------------------| -| `aud` | Agent's SPIFFE ID | `auth-target` | Agent's SPIFFE ID | `auth-target` | -| `azp` | Agent's SPIFFE ID | Agent's SPIFFE ID | Agent's SPIFFE ID | Agent's SPIFFE ID | -| `sub` | Service account ID | Service account ID | **alice's user ID** | **alice's user ID** | -| `preferred_username` | `service-account-spiffe://...` | same | **`alice`** | **`alice`** | -| `scope` | `agent-spiffe-aud profile email` | `auth-target-aud` | `agent-spiffe-aud profile email` | `auth-target-aud` | - -**Key Insight: Subject Preservation** -- The `sub` claim (user identity) is **preserved** through token exchange! -- For service accounts: `sub` remains the service account ID -- For users: `sub` remains alice's user ID - enabling user attribution at the target service -- The `azp` claim shows which client (Agent's SPIFFE ID) performed the exchange - -#### Why Subject Preservation Matters - -1. **User Attribution:** The target service knows the request is from alice, not just "some agent" -2. **Audit Trail:** `sub=`, `preferred_username=alice`, `azp=agent-spiffe-id` shows the full delegation chain -3. **Authorization Decisions:** Target can apply user-specific policies (alice's permissions) -4. **Compliance:** User actions are traceable even through agent intermediaries - -#### Security Model Benefits - -- The `agent-spiffe-aud` scope adds the Agent's SPIFFE ID to all tokens' audience -- The AuthProxy uses the same credentials as the registered client (matching the token's audience) -- No static secrets - credentials are dynamically generated by client-registration -- Clear audit trail via the `azp` claim -- Token exchange logic is handled by the sidecar, transparent to the application code - -## Verification - -### Check Client Registration - -```bash -kubectl logs deployment/agent -n authbridge -c client-registration -``` - -You should see: - -```shell -SPIFFE credentials ready! -Client ID (SPIFFE ID): spiffe://... -Created Keycloak client "spiffe://..." -Client registration complete! -``` - -### Check Token Exchange - -```bash -kubectl logs deployment/agent -n authbridge -c envoy-proxy 2>&1 | grep -i "token" -``` - -You should see: - -```shell -[Token Exchange] Configuration loaded, attempting token exchange -[Token Exchange] Successfully exchanged token, replacing Authorization header -``` - -### Check Auth Target - -```bash -kubectl logs deployment/auth-target -n authbridge -``` - -You should see: - -```shell -[JWT Debug] Successfully validated token -[JWT Debug] Audience: [auth-target] -Authorized request: GET /test -``` - -## Troubleshooting - -### Client Registration Can't Reach Keycloak - -**Symptom:** `Connection refused` when connecting to Keycloak - -**Fix:** Ensure `OUTBOUND_PORTS_EXCLUDE: "8080"` is set in proxy-init env vars. This excludes Keycloak port from iptables redirect. - -### Inbound JWT Validation Fails with "invalid issuer" - -**Symptom:** `{"error":"unauthorized","message":"token validation failed: invalid issuer: expected ..., got ..."}` - -**Cause:** The Keycloak frontend URL (used as the `iss` claim in tokens) differs from the internal service URL used in `TOKEN_URL`. This is common in Kubernetes where Keycloak is accessed externally via `keycloak.localtest.me` but internally via `keycloak-service.keycloak.svc`. - -**Fix:** Set the `ISSUER` field in the `auth-proxy-config` secret to match Keycloak's frontend URL: - -```yaml -stringData: - ISSUER: "http://keycloak.localtest.me:8080/realms/kagenti" -``` - -The `ISSUER` env var is required for inbound JWT validation. It must match the Keycloak frontend URL that appears as the `iss` claim in tokens. - -### Token Exchange Fails with "Audience not found" - -**Symptom:** `{"error":"invalid_client","error_description":"Audience not found"}` - -**Fix:** The `auth-target` client must exist in Keycloak. Run `setup_keycloak.py` which creates it. - -### Token Exchange Fails with "Client is not within the token audience" - -**Symptom:** Token exchange fails with error: -``` -{"error":"access_denied","error_description":"Client is not within the token audience"} -``` - -**Cause:** The caller's token doesn't include the Agent's SPIFFE ID in its audience. Keycloak requires the exchanging client to be in the token's audience for security reasons. - -**Fix:** Add the `agent-spiffe-aud` scope to the caller client: - -```bash -kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' -ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/master/protocol/openid-connect/token \ - -d "grant_type=password" -d "client_id=admin-cli" -d "username=admin" -d "password=admin" | jq -r ".access_token") - -SCOPE_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/client-scopes" | \ - jq -r ".[] | select(.name==\"agent-spiffe-aud\") | .id") - -CLIENT_ID=$(cat /shared/client-id.txt) -INTERNAL_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients?clientId=$CLIENT_ID" | jq -r ".[0].id") - -curl -s -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients/$INTERNAL_ID/default-client-scopes/$SCOPE_ID" - -echo "Added agent-spiffe-aud scope to $CLIENT_ID" -' -``` - -**Note:** This is a security feature, not a limitation. The `agent-spiffe-aud` scope adds the Agent's SPIFFE ID to the token's audience, authorizing the AuthProxy (using the same credentials) to exchange tokens. - -### Token Exchange Fails with "Client not enabled to retrieve service account" - -**Symptom:** `{"error":"unauthorized_client","error_description":"Client not enabled to retrieve service account"}` - -**Fix:** The caller's client needs `serviceAccountsEnabled: true`. This is set in the updated `client_registration.py`. - -### curl/jq Not Found in Agent Container - -**Symptom:** `sh: curl: not found` or `sh: jq: not found` - -**Fix:** The agent container should use `nicolaka/netshoot:latest` image which has these tools pre-installed. - -### No Token Received - -**Symptom:** `echo $TOKEN=null` - -**Fix:** Make sure the `serviceAccountsEnabled` is present in the `client-registration` image. - -#### Enable Service Accounts for the Registered Client - -The published `client-registration` image doesn't yet have the `serviceAccountsEnabled` fix. Run this to enable it: - -```bash -kubectl exec deployment/agent -n authbridge -c agent -- sh -c ' -CLIENT_ID=$(cat /shared/client-id.txt) -echo "Enabling service accounts for: $CLIENT_ID" - -ADMIN_TOKEN=$(curl -s http://keycloak-service.keycloak.svc:8080/realms/master/protocol/openid-connect/token \ - -d "grant_type=password" -d "client_id=admin-cli" -d "username=admin" -d "password=admin" | jq -r ".access_token") - -INTERNAL_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients?clientId=$CLIENT_ID" | jq -r ".[0].id") - -curl -s -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ - "http://keycloak-service.keycloak.svc:8080/admin/realms/kagenti/clients/$INTERNAL_ID" \ - -d "{\"clientId\": \"$CLIENT_ID\", \"serviceAccountsEnabled\": true}" - -echo "Done!" -' -``` - -### View All Logs - -```bash -# Agent pod containers -kubectl logs deployment/agent -n authbridge -c agent -kubectl logs deployment/agent -n authbridge -c client-registration -kubectl logs deployment/agent -n authbridge -c spiffe-helper -kubectl logs deployment/agent -n authbridge -c auth-proxy -kubectl logs deployment/agent -n authbridge -c envoy-proxy - -# Auth Target -kubectl logs deployment/auth-target -n authbridge -``` - -## Cleanup - -```bash -kubectl delete -f demos/single-target/k8s/authbridge-deployment.yaml -# OR -kubectl delete -f demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml - -# Delete the namespace (removes everything) -kubectl delete namespace authbridge -``` - -## Next Steps - -- See [AuthProxy Documentation](../../authproxy/README.md) for details on token validation and exchange -- See [Client Registration Documentation](../../client-registration/README.md) for details on automatic Keycloak registration -- See [AuthBridge README](../../README.md) for architecture overview diff --git a/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml b/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml deleted file mode 100644 index 1b0106f7a..000000000 --- a/authbridge/demos/single-target/k8s/authbridge-deployment-no-spiffe.yaml +++ /dev/null @@ -1,547 +0,0 @@ -# ⚠️ BROKEN AFTER kagenti-extensions#411 — same root cause as the -# matching SPIFFE-enabled deployment in this directory. See -# authbridge-deployment.yaml's header for the dead images. Applying -# this YAML today will produce ImagePullBackOff. -# -# 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. -# -# Flow: -# Inbound: Client -> Envoy (validates JWT via ext proc) -> AuthProxy (forwards) -# Outbound HTTP: AuthProxy -> Envoy (exchanges token via ext proc) -> Auth Target -# Outbound HTTPS: AuthProxy -> Envoy (TLS passthrough via tcp_proxy) -> Auth Target -# -# Pod 1: Caller Pod -# - Caller: The calling application -# - Client Registration (init): Registers caller with Keycloak using static client ID -# - AuthProxy: Simple pass-through proxy (JWT validation handled by ext proc) -# - Envoy: Intercepts inbound traffic (JWT validation) and outbound traffic (HTTP: token exchange; HTTPS: TLS passthrough) -# -# Pod 2: Auth Target (Target Server) -# - Validates tokens with audience "auth-target" - ---- -# ============================================================================= -# NAMESPACE AND SERVICE ACCOUNT -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: authbridge - labels: - istio.io/dataplane-mode: ambient - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: authbridge - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: caller - namespace: authbridge - ---- -# ============================================================================= -# AUTH PROXY CONFIGURATION -# CLIENT_ID and CLIENT_SECRET come from /shared/ files (populated by client-registration) -# ============================================================================= -apiVersion: v1 -kind: Secret -metadata: - name: auth-proxy-config - namespace: authbridge -type: Opaque -stringData: - TOKEN_URL: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/token" - ISSUER: "http://keycloak.localtest.me:8080/realms/kagenti" - ---- -# ============================================================================= -# CONFIGMAPS -# ============================================================================= -# Shared ConfigMap for environment variables -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: authbridge -data: - SPIRE_ENABLED: "false" - KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "kagenti" - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "admin" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" - ---- -# Envoy configuration for AuthProxy -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - namespace: authbridge -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - - name: envoy.filters.listener.tls_inspector - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector - filter_chains: - # TLS passthrough: forward HTTPS traffic as-is via TCP proxy - - filter_chain_match: - transport_protocol: tls - filters: - - name: envoy.filters.network.tcp_proxy - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy - stat_prefix: outbound_tls_passthrough - cluster: original_destination - # Plaintext HTTP: inspect and process via ext_proc - - filter_chain_match: - transport_protocol: raw_buffer - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - - name: inbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15124 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: inbound_http - codec_type: AUTO - route_config: - name: inbound_routes - virtual_hosts: - - name: local_app - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.lua - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua - inline_code: | - function envoy_on_request(request_handle) - request_handle:headers():add("x-authbridge-direction", "inbound") - end - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: ext_proc_cluster - connect_timeout: 5s - type: STATIC - http2_protocol_options: {} - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: ext_proc_cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 - - - name: original_destination - connect_timeout: 30s - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED - ---- -# Routes configuration for per-host token exchange settings -apiVersion: v1 -kind: ConfigMap -metadata: - name: authproxy-routes - namespace: authbridge -data: - routes.yaml: | - - host: "auth-target-service" - target_audience: "auth-target" - token_scopes: "openid auth-target-aud" - ---- -# ============================================================================= -# CALLER POD (No SPIFFE) -# Contains: Caller, Client Registration, AuthProxy -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: caller - namespace: authbridge - labels: - app: caller -spec: - replicas: 1 - selector: - matchLabels: - app: caller - template: - metadata: - labels: - app: caller - # istio.io/dataplane-mode: none - annotations: - # ambient.istio.io/redirection: disabled - sidecar.istio.io/inject: "false" - spec: - serviceAccountName: caller - imagePullSecrets: - - name: ghcr-secret - initContainers: - # Init container to set up iptables for traffic interception - - name: proxy-init - image: localhost/proxy-init:latest - imagePullPolicy: IfNotPresent - securityContext: - privileged: false - capabilities: - add: - - NET_ADMIN - - NET_RAW - drop: - - ALL - runAsNonRoot: false - runAsUser: 0 - env: - - name: PROXY_PORT - value: "15123" - - name: INBOUND_PROXY_PORT - value: "15124" - - name: PROXY_UID - value: "1337" - - name: OUTBOUND_PORTS_EXCLUDE - value: "8080" # Exclude Keycloak port from iptables redirect - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - resources: - limits: - cpu: 10m - memory: 10Mi - requests: - cpu: 10m - memory: 10Mi - # Client registration - registers caller with Keycloak using static client ID - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - | - # Save client ID to file (same as CLIENT_NAME when SPIRE is disabled) - echo "caller" > /shared/client-id.txt - echo "Starting client registration..." - python client_registration.py - echo "Client registration complete!" - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: KEYCLOAK_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_URL - - name: KEYCLOAK_REALM - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_REALM - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_PASSWORD - - name: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - - name: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - - name: CLIENT_NAME - value: caller - - name: SECRET_FILE_PATH - value: /shared/client-secret.txt - volumeMounts: - - name: shared-data - mountPath: /shared - containers: - # Caller - the calling application (uses netshoot with curl and jq) - - name: caller - image: nicolaka/netshoot:latest - command: - - /bin/sh - - -c - - | - echo "Caller pod ready!" - echo "Waiting for client registration to complete..." - 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!" - echo "Client ID: $CLIENT_ID" - echo "Client Secret: $CLIENT_SECRET" - echo "============================================" - echo "" - echo "To test, exec into this container and run:" - echo "" - echo " CLIENT_ID=\$(cat /shared/client-id.txt)" - echo " CLIENT_SECRET=\$(cat /shared/client-secret.txt)" - echo " TOKEN=\$(curl -sX POST http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\" - echo " -d 'grant_type=client_credentials' \\" - echo " -d \"client_id=\$CLIENT_ID\" \\" - echo " -d \"client_secret=\$CLIENT_SECRET\" | jq -r '.access_token')" - echo "" - echo " curl -H \"Authorization: Bearer \$TOKEN\" http://auth-target-service:8081/test" - echo "" - tail -f /dev/null - volumeMounts: - - name: shared-data - mountPath: /shared - # AuthProxy - pass-through proxy (JWT validation handled by inbound ext proc) - - name: auth-proxy - image: localhost/auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - # Envoy proxy - intercepts outgoing traffic and performs token exchange - - name: envoy-proxy - image: localhost/authbridge-unified:latest - imagePullPolicy: IfNotPresent - securityContext: - runAsUser: 1337 - runAsGroup: 1337 - ports: - - containerPort: 15123 - name: envoy-outbound - - containerPort: 15124 - name: envoy-inbound - - containerPort: 9901 - name: envoy-admin - - containerPort: 9090 - name: ext-proc - env: - # Token exchange configuration - - name: TOKEN_URL - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: TOKEN_URL - # Issuer for inbound JWT validation (must match Keycloak frontend URL / iss claim) - - name: ISSUER - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: ISSUER - # Dynamic credentials from client-registration (via files) - - name: CLIENT_ID_FILE - value: "/shared/client-id.txt" - - name: CLIENT_SECRET_FILE - value: "/shared/client-secret.txt" - # Route-based configuration - - name: ROUTES_CONFIG_PATH - value: "/etc/authproxy/routes.yaml" - volumeMounts: - - name: envoy-config - mountPath: /etc/envoy - readOnly: true - - name: shared-data - mountPath: /shared - readOnly: true - - name: routes-config - mountPath: /etc/authproxy - readOnly: true - resources: - limits: - cpu: 200m - memory: 256Mi - requests: - cpu: 50m - memory: 64Mi - volumes: - - name: shared-data - emptyDir: {} - - name: envoy-config - configMap: - name: envoy-config - - name: routes-config - configMap: - name: authproxy-routes - ---- -# ============================================================================= -# AUTH TARGET POD (Target Server) -# Validates tokens with audience "auth-target" -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-target - namespace: authbridge - labels: - app: auth-target -spec: - replicas: 1 - selector: - matchLabels: - app: auth-target - template: - metadata: - labels: - app: auth-target - spec: - containers: - - name: auth-target - image: ghcr.io/kagenti/kagenti-extensions/demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "auth-target" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - ---- -# Auth Target Service -apiVersion: v1 -kind: Service -metadata: - name: auth-target-service - namespace: authbridge - labels: - app: auth-target -spec: - type: ClusterIP - ports: - - port: 8081 - targetPort: 8081 - protocol: TCP - name: http - selector: - app: auth-target diff --git a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml b/authbridge/demos/single-target/k8s/authbridge-deployment.yaml deleted file mode 100644 index 8f0f0f7cb..000000000 --- a/authbridge/demos/single-target/k8s/authbridge-deployment.yaml +++ /dev/null @@ -1,643 +0,0 @@ -# ⚠️ BROKEN AFTER kagenti-extensions#411 — see ../demo.md and the demos -# index README. This manifest uses the pre-#411 multi-sidecar pattern -# (envoy-proxy + authbridge-light + client-registration + standalone -# spiffe-helper as separate containers). Several of those images no -# longer publish: -# - localhost/authbridge-unified:latest (image gone — replaced by authbridge / authbridge-envoy combined images) -# - ghcr.io/kagenti/kagenti-extensions/client-registration:latest (sidecar replaced by operator-managed registration) -# - ghcr.io/spiffe/spiffe-helper:nightly (now bundled inside the combined sidecar images, started by SPIRE_ENABLED) -# - localhost/auth-proxy:latest (demo-app image; rebuild from authbridge/authproxy/quickstart/) -# Applying this YAML today will produce ImagePullBackOff. Migration to -# the combined-sidecar shape is tracked separately. Use the webhook or -# weather-agent demos in the meantime. -# -# AuthBridge Demo Deployment (with SPIFFE) -# -# This deployment demonstrates: -# 1. Agent pod with automatic client registration using SPIFFE ID -# 2. AuthProxy sidecar intercepts inbound requests (JWT validation) and outbound requests (HTTP: token exchange; HTTPS: TLS passthrough) -# 3. Auth Target (target server) validates the exchanged tokens -# -# Flow: -# Inbound: Client -> Envoy (validates JWT via ext proc) -> Agent app -# Outbound HTTP: Agent app -> Envoy (exchanges token via ext proc) -> Auth Target -# Outbound HTTPS: Agent app -> Envoy (TLS passthrough via tcp_proxy) -> Auth Target -# -# 0. SPIFFE Helper obtains SVID (Agent's identity = SPIFFE ID) -# 1. Client Registration registers Agent with Keycloak -# 2. Caller gets token with aud: Agent's SPIFFE ID -# 3. Caller passes token to Agent -# 4. Envoy inbound listener validates JWT (signature + issuer via JWKS) -# 5. Agent calls Auth Target with Caller's token -# 6. Envoy outbound listener exchanges token (using Agent's credentials) -# 7. Auth Target validates exchanged token (aud: auth-target) -# -# Pod 1: Agent Pod -# - Agent: The agent application (receives tokens from Callers) -# - SPIFFE Helper: Provides SPIFFE credentials (SVID) -# - Client Registration: Registers Agent with Keycloak using SPIFFE ID -# - AuthProxy: Simple pass-through proxy (JWT validation handled by ext proc) -# - Envoy: Intercepts inbound traffic (JWT validation) and outbound traffic (HTTP: token exchange; HTTPS: TLS passthrough) -# -# Pod 2: Auth Target (Target Server) -# - Validates tokens with audience "auth-target" - ---- -# ============================================================================= -# NAMESPACE AND SERVICE ACCOUNT -# ============================================================================= -apiVersion: v1 -kind: Namespace -metadata: - name: authbridge - labels: - istio.io/dataplane-mode: ambient - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: authbridge - ---- -# ============================================================================= -# AUTH PROXY CONFIGURATION -# CLIENT_ID and CLIENT_SECRET come from /shared/ files (populated by client-registration) -# ============================================================================= -apiVersion: v1 -kind: Secret -metadata: - name: auth-proxy-config - namespace: authbridge -type: Opaque -stringData: - TOKEN_URL: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/token" - ISSUER: "http://keycloak.localtest.me:8080/realms/kagenti" - ---- -# ============================================================================= -# CONFIGMAPS -# ============================================================================= -# Shared ConfigMap for environment variables -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: authbridge -data: - SPIRE_ENABLED: "true" - KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "kagenti" - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "admin" - KEYCLOAK_TOKEN_EXCHANGE_ENABLED: "true" - KEYCLOAK_CLIENT_REGISTRATION_ENABLED: "true" - ---- -# SPIFFE Helper configuration for the caller pod -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: authbridge -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - svid_file_name = "/opt/svid.pem" - svid_key_file_name = "/opt/svid_key.pem" - svid_bundle_file_name = "/opt/svid_bundle.pem" - jwt_svids = [{jwt_audience="kagenti", jwt_svid_file_name="/opt/jwt_svid.token"}] - jwt_svid_file_mode = 0644 - include_federated_domains = true - ---- -# Envoy configuration for AuthProxy -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - namespace: authbridge -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - - name: envoy.filters.listener.tls_inspector - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector - filter_chains: - # TLS passthrough: forward HTTPS traffic as-is via TCP proxy - - filter_chain_match: - transport_protocol: tls - filters: - - name: envoy.filters.network.tcp_proxy - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy - stat_prefix: outbound_tls_passthrough - cluster: original_destination - # Plaintext HTTP: inspect and process via ext_proc - - filter_chain_match: - transport_protocol: raw_buffer - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - - name: inbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15124 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: inbound_http - codec_type: AUTO - route_config: - name: inbound_routes - virtual_hosts: - - name: local_app - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.lua - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua - inline_code: | - function envoy_on_request(request_handle) - request_handle:headers():add("x-authbridge-direction", "inbound") - end - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: ext_proc_cluster - connect_timeout: 5s - type: STATIC - http2_protocol_options: {} - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: ext_proc_cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 - - - name: original_destination - connect_timeout: 30s - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED - ---- -# Routes configuration for per-host token exchange settings -apiVersion: v1 -kind: ConfigMap -metadata: - name: authproxy-routes - namespace: authbridge -data: - routes.yaml: | - - host: "auth-target-service" - target_audience: "auth-target" - token_scopes: "openid auth-target-aud" - ---- -# ============================================================================= -# AGENT POD -# Contains: Agent, SPIFFE Helper, Client Registration, AuthProxy -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent - namespace: authbridge - labels: - app: agent -spec: - replicas: 1 - selector: - matchLabels: - app: agent - template: - metadata: - labels: - app: agent - # istio.io/dataplane-mode: none - annotations: - # ambient.istio.io/redirection: disabled - sidecar.istio.io/inject: "false" - spec: - serviceAccountName: agent - imagePullSecrets: - - name: ghcr-secret - initContainers: - # Init container to set up iptables for traffic interception - - name: proxy-init - image: localhost/proxy-init:latest - imagePullPolicy: IfNotPresent - securityContext: - privileged: false - capabilities: - add: - - NET_ADMIN - - NET_RAW - drop: - - ALL - runAsNonRoot: false - runAsUser: 0 - env: - - name: PROXY_PORT - value: "15123" - - name: INBOUND_PROXY_PORT - value: "15124" - - name: PROXY_UID - value: "1337" - - name: OUTBOUND_PORTS_EXCLUDE - value: "8080" # Exclude Keycloak port from iptables redirect - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - resources: - limits: - cpu: 10m - memory: 10Mi - requests: - cpu: 10m - memory: 10Mi - containers: - # Client registration - registers caller with Keycloak using SPIFFE ID - # Runs as a container (not init) because it needs SPIFFE Helper to be running - - name: client-registration - image: ghcr.io/kagenti/kagenti-extensions/client-registration:latest - command: - - /bin/sh - - -c - - | - echo "Waiting for SPIFFE credentials..." - while [ ! -f /opt/jwt_svid.token ]; do - echo "Waiting for SVID..." - 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) - # Add padding if needed and decode - 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!" - echo "Staying alive to keep pod running..." - tail -f /dev/null - env: - - name: SPIRE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: SPIRE_ENABLED - - name: KEYCLOAK_URL - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_URL - - name: KEYCLOAK_REALM - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_REALM - - name: KEYCLOAK_ADMIN_USERNAME - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_USERNAME - - name: KEYCLOAK_ADMIN_PASSWORD - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_ADMIN_PASSWORD - - name: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_TOKEN_EXCHANGE_ENABLED - - name: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - valueFrom: - configMapKeyRef: - name: authbridge-config - key: KEYCLOAK_CLIENT_REGISTRATION_ENABLED - - name: CLIENT_NAME - value: caller - - name: SECRET_FILE_PATH - value: /shared/client-secret.txt - volumeMounts: - - name: shared-data - mountPath: /shared - - name: svid-output - mountPath: /opt - # Agent - the agent application (receives tokens from Callers, calls external services) - # Use: kubectl exec -it deployment/agent -n authbridge -c agent -- sh - # Then: curl -H "Authorization: Bearer $TOKEN" http://auth-target-service:8081/test - - name: agent - image: nicolaka/netshoot:latest - command: - - /bin/sh - - -c - - | - echo "Agent pod ready!" - echo "Waiting for client registration to complete..." - 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!" - echo "Client ID (Agent's SPIFFE ID): $CLIENT_ID" - echo "Client Secret: $CLIENT_SECRET" - echo "============================================" - echo "" - echo "=== AuthBridge Flow ===" - echo "1. Caller gets token with aud: $CLIENT_ID (this Agent's identity)" - echo "2. Caller passes token to Agent" - echo "3. Agent calls Auth Target with Caller's token" - echo "4. AuthProxy exchanges token for aud: auth-target" - echo "" - echo "To test (simulating a Caller providing a token):" - echo "" - echo " # Get a token (as if Caller obtained it)" - echo " CLIENT_ID=\$(cat /shared/client-id.txt)" - echo " CLIENT_SECRET=\$(cat /shared/client-secret.txt)" - echo " TOKEN=\$(curl -sX POST http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\" - echo " -d 'grant_type=client_credentials' \\" - echo " -d \"client_id=\$CLIENT_ID\" \\" - echo " -d \"client_secret=\$CLIENT_SECRET\" | jq -r '.access_token')" - echo "" - echo " # Verify token audience (should be Agent's SPIFFE ID)" - echo " echo \$TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp}'" - echo "" - echo " # Agent calls auth-target (AuthProxy will exchange token)" - echo " curl -H \"Authorization: Bearer \$TOKEN\" http://auth-target-service:8081/test" - echo "" - # Keep container running - tail -f /dev/null - volumeMounts: - - name: shared-data - mountPath: /shared - # SPIFFE Helper - provides SPIFFE credentials (SVID) - - name: spiffe-helper - image: ghcr.io/spiffe/spiffe-helper:nightly - command: - - /spiffe-helper - - -config=/etc/spiffe-helper/helper.conf - - run - volumeMounts: - - name: spiffe-helper-config - mountPath: /etc/spiffe-helper - - name: spire-agent-socket - mountPath: /spiffe-workload-api - - name: svid-output - mountPath: /opt - # AuthProxy - pass-through proxy (JWT validation handled by inbound ext proc) - - name: auth-proxy - image: localhost/auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: ISSUER - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: ISSUER - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - # Envoy proxy - intercepts inbound traffic (JWT validation) and outbound traffic (HTTP: token exchange; HTTPS: TLS passthrough) - # Uses dynamic credentials from /shared/ (populated by client-registration) - # The SPIFFE ID is both the client_id AND the token audience, enabling exchange - - name: envoy-proxy - image: localhost/authbridge-unified:latest - imagePullPolicy: IfNotPresent - securityContext: - runAsUser: 1337 - runAsGroup: 1337 - ports: - - containerPort: 15123 - name: envoy-outbound - - containerPort: 15124 - name: envoy-inbound - - containerPort: 9901 - name: envoy-admin - - containerPort: 9090 - name: ext-proc - env: - # Token exchange configuration - - name: TOKEN_URL - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: TOKEN_URL - # Issuer for inbound JWT validation (must match Keycloak frontend URL / iss claim) - - name: ISSUER - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: ISSUER - # Dynamic credentials from client-registration (via files) - - name: CLIENT_ID_FILE - value: "/shared/client-id.txt" - - name: CLIENT_SECRET_FILE - value: "/shared/client-secret.txt" - # Route-based configuration - - name: ROUTES_CONFIG_PATH - value: "/etc/authproxy/routes.yaml" - volumeMounts: - - name: envoy-config - mountPath: /etc/envoy - readOnly: true - - name: shared-data - mountPath: /shared - readOnly: true - - name: routes-config - mountPath: /etc/authproxy - readOnly: true - resources: - limits: - cpu: 200m - memory: 256Mi - requests: - cpu: 50m - memory: 64Mi - volumes: - - name: shared-data - emptyDir: {} - - name: spiffe-helper-config - configMap: - name: spiffe-helper-config - - name: spire-agent-socket - hostPath: - path: /run/spire/agent-sockets - - name: svid-output - emptyDir: {} - - name: envoy-config - configMap: - name: envoy-config - - name: routes-config - configMap: - name: authproxy-routes - ---- -# ============================================================================= -# AUTH TARGET POD (Target Server) -# Validates tokens with audience "auth-target" -# ============================================================================= -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-target - namespace: authbridge - labels: - app: auth-target -spec: - replicas: 1 - selector: - matchLabels: - app: auth-target - template: - metadata: - labels: - app: auth-target - spec: - containers: - - name: auth-target - image: ghcr.io/kagenti/kagenti-extensions/demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - # Auth Target expects tokens with "auth-target" audience - - name: AUDIENCE - value: "auth-target" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - ---- -# Auth Target Service -apiVersion: v1 -kind: Service -metadata: - name: auth-target-service - namespace: authbridge - labels: - app: auth-target -spec: - type: ClusterIP - ports: - - port: 8081 - targetPort: 8081 - protocol: TCP - name: http - selector: - app: auth-target diff --git a/authbridge/demos/single-target/setup_keycloak.py b/authbridge/demos/single-target/setup_keycloak.py deleted file mode 100644 index 3db563790..000000000 --- a/authbridge/demos/single-target/setup_keycloak.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -setup_keycloak.py - AuthBridge Demo Setup - -This script configures Keycloak for the AuthBridge demo that combines: -1. Client Registration with SPIFFE ID (for the Agent pod identity) -2. AuthProxy sidecar for token exchange (using the auto-registered client) -3. Auth Target (target server) that validates exchanged tokens - -Architecture: - Caller → gets token (aud: Agent's SPIFFE ID) → passes to Agent - ↓ - Agent Pod (Agent + SPIFFE Helper + Client Registration + AuthProxy) - | - | Agent calls Auth Target with Caller's token - v - AuthProxy (Envoy) - validates token, exchanges using Agent's own credentials - | - | Token Exchange → audience "auth-target" - v - Auth Target (validates token has aud=auth-target) - -Clients created: -- auth-target: Target audience for token exchange (required by Keycloak) - -Client Scopes created: -- agent-spiffe-aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT - auto-included) -- auth-target-aud: Adds "auth-target" to token audience (realm OPTIONAL - for token exchange only) - -Demo Users created: -- alice: Demo user to demonstrate subject (sub) claim preservation through token exchange - Username: alice, Password: alice123 - -Note: The Agent workload is auto-registered by the client-registration container -using the SPIFFE ID as the client ID. The agent-spiffe-aud scope adds the Agent's -SPIFFE ID to all tokens as audience. This allows the AuthProxy (using the same -auto-registered credentials) to exchange tokens. - -IMPORTANT: The SPIFFE ID is hardcoded for the demo: - spiffe://localtest.me/ns/authbridge/sa/agent -If your namespace or service account differs, update AGENT_SPIFFE_ID below. -""" - -import sys - -from keycloak import KeycloakAdmin, KeycloakPostError - -KEYCLOAK_URL = "http://keycloak.localtest.me:8080" -KEYCLOAK_REALM = "kagenti" -KEYCLOAK_ADMIN_USERNAME = "admin" -KEYCLOAK_ADMIN_PASSWORD = "admin" - -# SPIFFE ID for the Agent pod (namespace: authbridge, serviceAccount: agent) -# Update this if your deployment uses different namespace/serviceAccount -AGENT_SPIFFE_ID = "spiffe://localtest.me/ns/authbridge/sa/agent" - -# Demo user for demonstrating subject preservation -DEMO_USER = { - "username": "alice", - "email": "alice@example.com", - "firstName": "Alice", - "lastName": "Demo", - "password": "alice123", -} - - -def get_or_create_realm(keycloak_admin, realm_name): - """Create realm if it doesn't exist.""" - try: - realms = keycloak_admin.get_realms() - for realm in realms: - 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, - } - ) - print(f"Created realm '{realm_name}'.") - except Exception as e: - print(f"Error checking/creating realm: {e}") - - -def get_or_create_client(keycloak_admin, client_payload): - """Create client if doesn't exist, return internal client ID.""" - 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.") - return existing_client_id - internal_id = keycloak_admin.create_client(client_payload) - print(f"Created client '{client_id}'.") - return internal_id - - -def get_or_create_client_scope(keycloak_admin, scope_payload): - """Create client scope if doesn't exist, return scope ID.""" - scope_name = scope_payload.get("name") - scopes = keycloak_admin.get_client_scopes() - for scope in scopes: - if scope["name"] == scope_name: - print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope["id"] - - try: - scope_id = keycloak_admin.create_client_scope(scope_payload) - print(f"Created client scope '{scope_name}': {scope_id}") - return scope_id - except KeycloakPostError as e: - print(f"Could not create client scope '{scope_name}': {e}") - raise - - -def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): - """Add audience protocol mapper to a client scope.""" - mapper_payload = { - "name": mapper_name, - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": False, - "config": { - "included.custom.audience": audience, - "id.token.claim": "false", - "access.token.claim": "true", - "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: - # Mapper might already exist - print(f"Note: Could not add mapper '{mapper_name}' (might already exist): {e}") - - -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}], - } - ) - print(f"Created user '{username}' with ID: {user_id}") - return user_id - except KeycloakPostError as e: - print(f"Could not create user '{username}': {e}") - raise - - -def main(): - print("=" * 60) - 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: - master_admin = KeycloakAdmin( - server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USERNAME, - password=KEYCLOAK_ADMIN_PASSWORD, - realm_name="master", - user_realm_name="master", - ) - except Exception as e: - print(f"Failed to connect to Keycloak: {e}") - print("\nMake sure Keycloak is running and accessible at:") - print(f" {KEYCLOAK_URL}") - 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", - ) - - # 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"}, - }, - ) - - # 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("\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"}, - }, - ) - 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 - try: - keycloak_admin.add_default_default_client_scope(agent_spiffe_scope_id) - 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 - try: - keycloak_admin.add_default_optional_client_scope(auth_target_scope_id) - 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 \\ - http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\ - -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' \\ - -d "client_id=$CLIENT_ID" \\ - -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}}' - - # 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) - print(f""" -1. Agent pod starts and registers with Keycloak using its SPIFFE ID: - client_id = {AGENT_SPIFFE_ID} - -2. Credentials are saved to /shared/client-id.txt and /shared/client-secret.txt - -3. When a Caller gets a token, it has: - aud: {AGENT_SPIFFE_ID} (via agent-spiffe-aud realm default scope) - -4. AuthProxy intercepts outgoing requests and exchanges the token using - the same credentials from /shared/ (matching the token's audience) - -5. The exchanged token has aud: auth-target - -No pre-configured 'agent' client needed - the agent registers itself -dynamically and AuthProxy uses the resulting client credentials! -""") - - -if __name__ == "__main__": - main() diff --git a/authbridge/demos/token-exchange-routes/README.md b/authbridge/demos/token-exchange-routes/README.md new file mode 100644 index 000000000..3eaf59640 --- /dev/null +++ b/authbridge/demos/token-exchange-routes/README.md @@ -0,0 +1,205 @@ +# Configuring Token Exchange Routes + +This guide explains how AuthBridge's outbound `token-exchange` plugin +turns a workload's outbound HTTP requests into properly-audienced +tokens, **without** changing the agent's code. It covers both single- +and multi-target setups in one place — the difference is just how +many entries you put in the `authproxy-routes` ConfigMap. + +This is a **configuration reference**, not an end-to-end demo. +Pair it with one of the deployment demos for a working stack: + +- [`weather-agent/demo-ui-advanced.md`](../weather-agent/demo-ui-advanced.md) + — agent + tool with token exchange, runs end-to-end. +- [`webhook/README.md`](../webhook/README.md) — manual webhook + injection with the auth-target demo app. + +## How outbound routing works + +When AuthBridge's outbound chain runs, the `token-exchange` plugin +matches the request's HTTP `Host` header against entries in the +`authproxy-routes` ConfigMap. The first match wins. On a hit, the +plugin: + +1. Reads the caller's `Authorization: Bearer ` header. +2. Performs an RFC 8693 token exchange against Keycloak with + `audience=` and `scope=`, + using the workload's own client credentials as the authenticated + client. +3. Replaces the request's `Authorization` header with the exchanged + token before the request leaves the sidecar. + +If no route matches, the plugin falls back to its `default_policy` +(`passthrough` by default — the original token is forwarded +unchanged; `exchange` would have it block any unrouted host). See +[`authbridge/docs/plugin-reference.md`](../../docs/plugin-reference.md) +for the full plugin contract. + +The `Host` header value is what the HTTP client puts in the URL +hostname — for in-cluster calls this is typically a **short +Kubernetes Service name** (e.g., `weather-tool-mcp`), not the FQDN. + +## ConfigMap shape + +The plugin reads `routes.yaml` from a ConfigMap named +`authproxy-routes` in the same namespace as the workload. The file +is a YAML list of route entries: + +```yaml +# authproxy-routes ConfigMap, key: routes.yaml +- host: "weather-tool-mcp" + target_audience: "weather-tool" + token_scopes: "openid weather-tool-aud" +``` + +Apply with: + +```bash +kubectl create configmap authproxy-routes \ + -n team1 \ + --from-file=routes.yaml=routes.yaml \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +Per-route fields: + +| Field | Purpose | +|---|---| +| `host` | Glob pattern matched against the request's `Host` header. `filepath.Match` semantics (`*`, `?`, `[...]`). | +| `target_audience` | OAuth audience to request from Keycloak. Must exist as a Keycloak client. | +| `token_scopes` | Space-separated list of scopes to request. Each scope must be assigned (default or optional) to the workload's own Keycloak client. | + +## Single-target example + +The simplest case — one workload with one outbound target. + +`authproxy-routes` (one entry): + +```yaml +- host: "weather-tool-mcp" + target_audience: "weather-tool" + token_scopes: "openid weather-tool-aud" +``` + +Keycloak needs: + +- A client with `clientId: "weather-tool"` (the target audience). +- Client scope `weather-tool-aud` set to **optional** on the + agent's own Keycloak client (so it can request the scope at + exchange time). +- The `weather-tool-aud` scope's audience mapper set to add + `"weather-tool"` to the resulting token's `aud` claim. + +When the agent makes any HTTP call to `http://weather-tool-mcp/...`, +AuthBridge intercepts it, runs the exchange, and replaces the +`Authorization` header. The agent's source code never knows token +exchange happened. + +## Multi-target example + +Same shape, just multiple entries — `authproxy-routes` is a single +list, evaluated top-to-bottom. The first matching `host` wins, so +order more-specific patterns before broader ones. + +`routes.yaml` in this directory shows the canonical multi-target +example with three target audiences: + +```yaml +- host: "target-alpha-service**" + target_audience: "target-alpha" + token_scopes: "openid target-alpha-aud" + +- host: "target-beta-service**" + target_audience: "target-beta" + token_scopes: "openid target-beta-aud" + +- host: "target-gamma-service**" + target_audience: "target-gamma" + token_scopes: "openid target-gamma-aud" +``` + +Each target audience requires its own Keycloak client + audience +scope, and the agent's own Keycloak client needs each +`*-aud` scope assigned (optional). + +## Mixing exchange and passthrough + +The default policy when no route matches is `passthrough`. That's +what makes mixed deployments natural — for example, an agent that +calls one tool with token exchange and a public LLM endpoint +without: + +```yaml +# Match: token-exchange runs. +- host: "github-tool-mcp" + target_audience: "github-tool" + token_scopes: "openid github-tool-aud" + +# No entry for ollama / openai / etc. — outbound passthrough applies. +``` + +Switching the default to `exchange` (so unrouted hosts are +**rejected** with 503 instead of forwarded) is a per-plugin +setting: + +```yaml +# pipeline.outbound.plugins[*].config (in authbridge-runtime-config) +- name: token-exchange + config: + default_policy: "exchange" + routes: + file: "/etc/authproxy/routes.yaml" +``` + +Use this when the workload should never reach an unauthenticated +upstream — typical for tool-only namespaces. + +## Glob pattern tips + +- `*` matches any sequence of non-`/` characters within a path + segment. For Host headers (no `/`) it effectively matches + anything. +- `**` is **not** a special pattern in `filepath.Match` — it's + the same as `*`. Multi-segment matches just need a single `*`. +- `host: "*-tool-mcp"` matches `weather-tool-mcp`, `github-tool-mcp`, + etc. +- `host: "internal-*"` matches any short service name starting with + `internal-`. +- Exact-match host strings (no glob characters) are valid and the + cheapest at runtime. + +## Troubleshooting + +**Symptom:** `503` from the outbound side / `default_policy=exchange` +rejected the call. +- Check the `Host` header the agent actually sent (`kubectl logs` + on the AuthBridge sidecar — `authbridge-proxy` in proxy-sidecar + mode, `envoy-proxy` in envoy-sidecar mode). +- Confirm the host string in `authproxy-routes` matches that header. +- Glob mismatch is the most common cause; try the literal short + service name first. + +**Symptom:** Token exchange returns `400 invalid_scope` from Keycloak. +- The scope in `token_scopes` is not assigned to the agent's own + Keycloak client. Add it as an optional client scope. + +**Symptom:** Target service rejects the exchanged token with +`aud` mismatch. +- The target's expected audience doesn't match `target_audience` in + the route. Check the target's Keycloak client (its `clientId` + determines the audience clients can request). + +**Symptom:** Routes update doesn't take effect. +- The `token-exchange` plugin reads `routes.yaml` once at Configure + time. After editing the ConfigMap, restart the workload pod + (`kubectl rollout restart deployment/ -n `) so the + sidecar picks up the new content. + +## See also + +- [`authbridge/docs/plugin-reference.md`](../../docs/plugin-reference.md) + — full `token-exchange` plugin reference. +- [`weather-agent/demo-ui-advanced.md`](../weather-agent/demo-ui-advanced.md) + — end-to-end demo that exercises the single-target route pattern. +- [`webhook/README.md`](../webhook/README.md) — webhook injection + walkthrough that the routes here plug into. diff --git a/authbridge/demos/multi-target/routes.yaml b/authbridge/demos/token-exchange-routes/routes.yaml similarity index 93% rename from authbridge/demos/multi-target/routes.yaml rename to authbridge/demos/token-exchange-routes/routes.yaml index ba5b13f67..34d093912 100644 --- a/authbridge/demos/multi-target/routes.yaml +++ b/authbridge/demos/token-exchange-routes/routes.yaml @@ -1,4 +1,4 @@ -# Multi-Target Demo Routes Configuration +# Token-Exchange Routes — Multi-Target Example # # Each route maps a destination host to token exchange parameters. # Order matters - first match wins. diff --git a/authbridge/demos/weather-agent/demo-ui.md b/authbridge/demos/weather-agent/demo-ui.md index 2f8e3cb51..66249e2d9 100644 --- a/authbridge/demos/weather-agent/demo-ui.md +++ b/authbridge/demos/weather-agent/demo-ui.md @@ -787,6 +787,6 @@ kubectl delete namespace team1 outbound token exchange, scope-based access control, and Alice vs Bob scenarios - **AuthBridge Binary**: See the [AuthBridge README](../../cmd/authbridge/README.md) for inbound JWT validation and outbound token exchange internals -- **Multi-Target Demo**: See the [multi-target demo](../multi-target/demo.md) for +- **Token-Exchange Routes**: See the [routes-configuration guide](../token-exchange-routes/README.md) for route-based token exchange to multiple tool services - **AuthBridge Overview**: See the [AuthBridge README](../../README.md) for architecture details diff --git a/authbridge/demos/webhook/setup_keycloak.py b/authbridge/demos/webhook/setup_keycloak.py index 2f2b070b1..556a5534e 100644 --- a/authbridge/demos/webhook/setup_keycloak.py +++ b/authbridge/demos/webhook/setup_keycloak.py @@ -351,8 +351,8 @@ def main(): jwt_svid_file_mode = 0644 EOF -# 4. envoy-config ConfigMap (for envoy-proxy) -# Copy from authbridge/demos/single-target/k8s/authbridge-deployment.yaml or use: +# 4. envoy-config ConfigMap (for envoy-proxy in envoy-sidecar mode) +# Copy from the kagenti-system release namespace, or use: kubectl get configmap envoy-config -n authbridge -o yaml | \\ sed 's/namespace: authbridge/namespace: {namespace}/' | \\ kubectl apply -f - From c3444c80d74165749d75cb4a6a2cbfc863531cad Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 17:36:36 -0400 Subject: [PATCH 6/9] docs(github-issue): rewrite for combined sidecar shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The github-issue demo (the recommended-path "intermediate" demo) was written for the pre-#411 multi-sidecar pattern: four containers per agent pod (`agent` + `spiffe-helper` + `kagenti-client-registration` + `envoy-proxy`), all `kubectl exec/logs -c ` blocks targeting names that no longer exist, and a `combinedSidecar` feature gate that was removed. Anyone following the demo today hits container-not-found errors at every verification step. Updates across all four files: * `demo.md` (index) — architecture component list rewritten: two-container pod (agent + AuthBridge sidecar; container name depends on resolved mode), spiffe-helper bundled inside, client registration operator-managed. * `k8s/git-issue-agent-deployment.yaml` — top-of-file injection comment rewritten to describe the post-#411 shape: one combined sidecar with mode-dependent container name; proxy-init only in envoy-sidecar mode; operator-managed registration via the `kagenti-keycloak-client-credentials-` Secret mounted at `/shared/`. * `demo-manual.md` — full rewrite of the architecture diagram and every verification block: - Agent pod ASCII diagram: 3+1 boxes collapsed into 1+1, with explicit per-mode container names and operator-managed registration callout. - "Verify injected containers" step: expected output flipped from `agent spiffe-helper kagenti-client-registration envoy-proxy` to `agent authbridge-proxy` (or `agent envoy-proxy` for envoy-sidecar). Pod ready count `4/4` → `2/2`. - "Check client registration" step: replaced the `-c kagenti-client-registration` log inspection with a Secret-name-based mount check + a kagenti-controller-manager log filter for the actual reconciler activity. - All `kubectl exec/logs -c envoy-proxy` blocks: rewritten to auto-detect the sidecar container name via a `$SIDECAR` shell variable that greps for `authbridge-proxy|envoy-proxy`. - "Agent Pod Not Starting (4/4 containers)" troubleshooting: rewritten for 1/2 / 0/2 with the operator log fallback added for stuck-on-shared-secret cases. * `demo-ui.md` — same treatment as demo-manual.md, plus: - Architecture diagram parallel update. - "Sidecar Modes" section that previously documented two branches (legacy `combinedSidecar: false` vs combined `combinedSidecar: true`) collapsed into a single description of the post-#411 cluster default. The link to the `combinedSidecar` feature-gate doc removed (the gate is gone). - "Agent card not available" troubleshooting block: log inspection rewritten to use the `$SIDECAR` auto-detect. Net effect: every command in the demo now works against either proxy-sidecar (cluster default) or envoy-sidecar mode without manual fixup, and every architecture description matches what the operator actually injects post-#411. Verified: `grep -n` for `kagenti-client-registration` returns zero hits across all four files; remaining `envoy-proxy` matches are all in the per-mode comments and shell auto-detect blocks (i.e., correctly describing envoy-sidecar mode), not stale references. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/github-issue/demo-manual.md | 160 +++++++++++------ authbridge/demos/github-issue/demo-ui.md | 164 ++++++++++-------- authbridge/demos/github-issue/demo.md | 17 +- .../k8s/git-issue-agent-deployment.yaml | 22 ++- 4 files changed, 220 insertions(+), 143 deletions(-) diff --git a/authbridge/demos/github-issue/demo-manual.md b/authbridge/demos/github-issue/demo-manual.md index f444c9aa2..fde1edef6 100644 --- a/authbridge/demos/github-issue/demo-manual.md +++ b/authbridge/demos/github-issue/demo-manual.md @@ -32,22 +32,27 @@ providing end-to-end security: │ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ GIT-ISSUE-AGENT POD (namespace: team1) │ │ │ │ │ │ -│ │ ┌──────────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │ │ -│ │ │ git-issue-agent │ │ spiffe- │ │ client-registration │ │ │ -│ │ │ (A2A agent, │ │ helper │ │ (registers with Keycloak │ │ │ -│ │ │ port 8000) │ │ │ │ using SPIFFE ID) │ │ │ -│ │ └──────────────────┘ └─────────────┘ └──────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ AuthProxy Sidecar (envoy-proxy container) │ │ │ -│ │ │ Envoy + ext_proc (authbridge) │ │ │ -│ │ │ Inbound (port 15124): │ │ │ -│ │ │ - Validates JWT (signature + issuer + audience via JWKS) │ │ │ -│ │ │ - Returns 401 Unauthorized for invalid/missing tokens │ │ │ -│ │ │ Outbound (port 15123): │ │ │ -│ │ │ - HTTP: Exchanges token via Keycloak → aud: github-tool │ │ │ -│ │ │ - HTTPS: TLS passthrough (no interception) │ │ │ -│ │ └───────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │ │ +│ │ │ git-issue-agent │ │ AuthBridge sidecar (combined image) │ │ │ +│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ +│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy│ │ │ +│ │ └──────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ +│ │ │ │ │ │ +│ │ │ Inbound: │ │ │ +│ │ │ - Validates JWT (signature + issuer + │ │ │ +│ │ │ audience via JWKS) │ │ │ +│ │ │ - Returns 401 for invalid/missing tokens │ │ │ +│ │ │ Outbound: │ │ │ +│ │ │ - HTTP: Exchanges token via Keycloak │ │ │ +│ │ │ → aud: github-tool │ │ │ +│ │ │ - HTTPS: TLS passthrough │ │ │ +│ │ │ │ │ │ +│ │ │ spiffe-helper bundled inside the image │ │ │ +│ │ │ (gated by SPIRE_ENABLED). │ │ │ +│ │ │ Keycloak client registration is │ │ │ +│ │ │ operator-managed; the resulting Secret │ │ │ +│ │ │ is mounted at /shared/client-{id,secret}.txt│ │ │ +│ │ └────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ Exchanged token │(aud: github-tool) │ @@ -350,9 +355,9 @@ kubectl wait --for=condition=available --timeout=120s deployment/github-tool -n ## Step 6: Deploy the GitHub Issue Agent -Deploy the agent with AuthBridge labels. The webhook will automatically inject -the AuthBridge sidecars (spiffe-helper, client-registration, envoy-proxy) and the -proxy-init init container: +Deploy the agent with AuthBridge labels. The webhook will automatically +inject one combined AuthBridge sidecar (post-#411). In envoy-sidecar mode +it also injects a `proxy-init` init container for iptables setup: ```bash kubectl apply -f demos/github-issue/k8s/git-issue-agent-deployment.yaml @@ -360,21 +365,30 @@ kubectl apply -f demos/github-issue/k8s/git-issue-agent-deployment.yaml kubectl wait --for=condition=available --timeout=180s deployment/git-issue-agent -n team1 ``` -> **Note:** The agent may take longer to start because it waits for SPIFFE credentials -> and Keycloak client registration to complete before becoming ready. +> **Note:** The agent may take longer to start because it waits on +> `/shared/client-{id,secret}.txt` to be populated by the operator's +> `ClientRegistrationReconciler` before the AuthBridge sidecar becomes +> ready. ### Verify injected containers -Confirm that the webhook injected the AuthBridge sidecars: +Confirm that the webhook injected the combined AuthBridge sidecar: ```bash -kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent -o jsonpath='{.items[0].spec.containers[*].name}' +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' ``` -Expected output (with SPIFFE): +Expected (proxy-sidecar mode, the cluster default): ```txt -agent spiffe-helper kagenti-client-registration envoy-proxy +agent authbridge-proxy +``` + +Or, in envoy-sidecar mode: + +```txt +agent envoy-proxy ``` --- @@ -391,23 +405,37 @@ Expected output: ``` NAME READY STATUS RESTARTS AGE -git-issue-agent-58768bdb67-xxxxx 4/4 Running 0 2m +git-issue-agent-58768bdb67-xxxxx 2/2 Running 0 2m github-tool-7f8c9d6b44-yyyyy 1/1 Running 0 3m ``` -### Check client registration +### Check operator-managed client registration + +After kagenti-extensions#411 / kagenti-operator#361, registration runs in +the kagenti-operator (outside the workload pod). Verify the resulting +Secret was mounted into the agent's sidecar: ```bash -kubectl logs deployment/git-issue-agent -n team1 -c kagenti-client-registration +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.volumes[?(@.secret)].secret.secretName}' +# Expect a Secret name starting with: kagenti-keycloak-client-credentials- ``` -Expected: +Follow the operator-side registration: + +```bash +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 +``` + +Expected (operator log lines, exact format depends on the operator's +log format): ``` -SPIFFE credentials ready! -Client ID (SPIFFE ID): spiffe://localtest.me/ns/team1/sa/git-issue-agent -Created Keycloak client "spiffe://localtest.me/ns/team1/sa/git-issue-agent" -Client registration complete! +ClientRegistrationReconciler: ensured Keycloak client + spiffe://localtest.me/ns/team1/sa/git-issue-agent +ClientRegistrationReconciler: wrote Secret + kagenti-keycloak-client-credentials- ``` ### Check agent logs @@ -433,10 +461,11 @@ INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) > **These warnings are expected and harmless.** The agent's built-in auth code > probes for SVID and client-secret files at startup. With AuthBridge, these files -> are used by the sidecars (spiffe-helper, client-registration, Envoy), not by the -> agent container directly. The agent falls back to JWKS-based JWT validation -> (`JWKS_URI is set`), which is the correct behavior — AuthBridge's Envoy sidecar -> handles inbound JWT validation and outbound token exchange on behalf of the agent. +> are produced and consumed inside the AuthBridge sidecar (and the operator's +> ClientRegistrationReconciler), not by the agent container directly. The agent +> falls back to JWKS-based JWT validation (`JWKS_URI is set`), which is the +> correct behavior — AuthBridge handles inbound JWT validation and outbound +> token exchange on behalf of the agent. > These warnings will be removed once the agent's built-in auth logic is cleaned up > ([kagenti/agent-examples#129](https://github.com/kagenti/agent-examples/issues/129)). @@ -542,9 +571,18 @@ kubectl exec test-client -n team1 -- curl -s \ ### 8e. Valid Token - Agent Card ```bash -# Get the agent's client credentials -CLIENT_ID=$(kubectl exec deployment/git-issue-agent -n team1 -c envoy-proxy -- cat /shared/client-id.txt) -CLIENT_SECRET=$(kubectl exec deployment/git-issue-agent -n team1 -c envoy-proxy -- cat /shared/client-secret.txt) +# The AuthBridge sidecar's container name depends on the resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +SIDECAR=$(kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' | tr ' ' '\n' \ + | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) + +# Get the agent's client credentials (mounted by the operator-managed +# ClientRegistration controller via the kagenti-keycloak-client-credentials +# Secret, then mounted into the sidecar at /shared/). +CLIENT_ID=$(kubectl exec deployment/git-issue-agent -n team1 -c "$SIDECAR" -- cat /shared/client-id.txt) +CLIENT_SECRET=$(kubectl exec deployment/git-issue-agent -n team1 -c "$SIDECAR" -- cat /shared/client-secret.txt) echo "Agent Client ID: $CLIENT_ID" # Get a service account token (simulating what the UI would obtain) @@ -577,12 +615,16 @@ kubectl exec test-client -n team1 -- curl -s \ Verify that AuthProxy validated (and rejected) the inbound requests from steps 8b–8e. -> **Tip:** The `envoy-proxy` container runs both Envoy and authbridge (ext_proc). -> Inbound validation messages from authbridge include the `[Inbound]` marker. -> Filter by `"[Inbound]"` to see only inbound validation output. +> **Tip:** The combined AuthBridge sidecar runs the inbound JWT validation +> plugin. Inbound validation messages include the `[Inbound]` marker — +> filter by `"[Inbound]"` to see only inbound output. +> +> The container name depends on the resolved mode (`authbridge-proxy` for +> proxy-sidecar, `envoy-proxy` for envoy-sidecar). The `$SIDECAR` shell +> variable from §8e auto-detects it. ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep "\[Inbound\]" +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "\[Inbound\]" ``` Expected (one line per request in 8b–8e): @@ -751,14 +793,15 @@ curl -s --max-time 10 \ ### 9e. Verify AuthProxy Logs (Inbound + Outbound) -Check the ext_proc logs to confirm both inbound validation and outbound token -exchange are working. The `envoy-proxy` container runs the authbridge ext_proc that -handles both directions. +Check the AuthBridge sidecar logs to confirm both inbound validation and +outbound token exchange are working. The combined sidecar handles both +directions; the container name depends on the resolved mode +(`authbridge-proxy` for proxy-sidecar, `envoy-proxy` for envoy-sidecar). **Inbound validation logs** (JWT signature, issuer, audience checks): ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep -i "inbound" +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep -i "inbound" ``` Expected output: @@ -779,7 +822,7 @@ If you ran the rejection tests (8b, 8c, 8d), you should also see: **Outbound token exchange logs** (RFC 8693 token exchange for the GitHub tool): ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep "^2026/" | grep "\[Token Exchange\]" +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "^2026/" | grep "\[Token Exchange\]" ``` Expected: @@ -1058,17 +1101,24 @@ Check that `OUTBOUND_PORTS_EXCLUDE: "8080"` is set in the proxy-init env vars. **Fix:** The `agent-team1-git-issue-agent-aud` scope must be a realm default. Run `setup_keycloak.py` to set it up. -### Agent Pod Not Starting (4/4 containers) +### Agent Pod Not Starting -**Symptom:** Pod shows 3/4 or less containers ready +**Symptom:** Pod shows 1/2 (or 0/2) containers ready. -**Fix:** Check each container's logs: +**Fix:** Check the agent and the AuthBridge sidecar; if the issue is +operator-managed registration not finishing, the workload pod waits on +`/shared/client-{id,secret}.txt`. ```bash -kubectl logs deployment/git-issue-agent -n team1 -c kagenti-client-registration -kubectl logs deployment/git-issue-agent -n team1 -c spiffe-helper -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy +# AuthBridge sidecar — name depends on resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +kubectl logs deployment/git-issue-agent -n team1 -c authbridge-proxy kubectl logs deployment/git-issue-agent -n team1 -c agent + +# Operator-managed registration: +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 ``` ### GitHub Tool Returns 401 diff --git a/authbridge/demos/github-issue/demo-ui.md b/authbridge/demos/github-issue/demo-ui.md index 116b194a8..67931380d 100644 --- a/authbridge/demos/github-issue/demo-ui.md +++ b/authbridge/demos/github-issue/demo-ui.md @@ -35,22 +35,27 @@ providing end-to-end security: │ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ │ GIT-ISSUE-AGENT POD (namespace: team1) │ │ │ │ │ │ -│ │ ┌─────────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │ │ -│ │ │ git-issue-agent │ │ spiffe- │ │ client-registration │ │ │ -│ │ │ (A2A agent, │ │ helper │ │ (registers with Keycloak │ │ │ -│ │ │ port 8000) │ │ │ │ using SPIFFE ID) │ │ │ -│ │ └─────────────────┘ └─────────────┘ └──────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ AuthProxy Sidecar (envoy-proxy container) │ │ │ -│ │ │ Envoy + ext_proc (authbridge) │ │ │ -│ │ │ Inbound (port 15124): │ │ │ -│ │ │ - Validates JWT (signature + issuer + audience via JWKS) │ │ │ -│ │ │ - Returns 401 Unauthorized for invalid/missing tokens │ │ │ -│ │ │ Outbound (port 15123): │ │ │ -│ │ │ - HTTP: Exchanges token via Keycloak → aud: github-tool │ │ │ -│ │ │ - HTTPS: TLS passthrough (no interception) │ │ │ -│ │ └───────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────┐ ┌────────────────────────────────────────────┐ │ │ +│ │ │ git-issue-agent │ │ AuthBridge sidecar (combined image) │ │ │ +│ │ │ (A2A agent, │ │ Container name depends on resolved mode: │ │ │ +│ │ │ port 8000) │ │ proxy-sidecar (default): authbridge-proxy│ │ │ +│ │ └─────────────────┘ │ envoy-sidecar: envoy-proxy │ │ │ +│ │ │ │ │ │ +│ │ │ Inbound: │ │ │ +│ │ │ - Validates JWT (signature + issuer + │ │ │ +│ │ │ audience via JWKS) │ │ │ +│ │ │ - Returns 401 for invalid/missing tokens │ │ │ +│ │ │ Outbound: │ │ │ +│ │ │ - HTTP: Exchanges token via Keycloak │ │ │ +│ │ │ → aud: github-tool │ │ │ +│ │ │ - HTTPS: TLS passthrough │ │ │ +│ │ │ │ │ │ +│ │ │ spiffe-helper bundled inside the image │ │ │ +│ │ │ (gated by SPIRE_ENABLED). │ │ │ +│ │ │ Keycloak client registration is │ │ │ +│ │ │ operator-managed; the resulting Secret │ │ │ +│ │ │ is mounted at /shared/client-{id,secret}.txt│ │ │ +│ │ └────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ Exchanged token │(aud: github-tool) │ @@ -384,23 +389,8 @@ Wait for the Shipwright build to complete and the deployment to become ready. kubectl get pods -n team1 ``` -Expected output depends on how the **kagenti-operator** feature gate -[`combinedSidecar`](https://github.com/kagenti/kagenti/blob/main/docs/authbridge/deployment-guide.md) -is set (cluster-wide Helm / `kagenti-feature-gates` ConfigMap — not the import UI). - -**Legacy separate sidecars** (`combinedSidecar: false`, default in many installs): - -``` -NAME READY STATUS RESTARTS AGE -git-issue-agent-58768bdb67-xxxxx 4/4 Running 0 2m -github-tool-7f8c9d6b44-yyyyy 1/1 Running 0 5m -``` - -> **Note:** The agent pod shows **4/4** — the agent container plus three AuthBridge -> sidecars (envoy-proxy, spiffe-helper, kagenti-client-registration) and an init -> container (`proxy-init`) that does not count toward `READY` the same way. - -**Combined AuthBridge** (`combinedSidecar: true`, [kagenti-extensions#254](https://github.com/kagenti/kagenti-extensions/pull/254)): +Expected output (after kagenti-extensions#411 / kagenti-operator#361: +one combined AuthBridge sidecar, registration is operator-managed): ``` NAME READY STATUS RESTARTS AGE @@ -408,50 +398,58 @@ git-issue-agent-77fc7dc6cd-xxxxx 2/2 Running 0 2m github-tool-7f8c9d6b44-yyyyy 1/1 Running 0 5m ``` -> **Note:** The agent pod shows **2/2** — the **agent** container plus a single -> **authbridge** container (Envoy, authbridge, spiffe-helper, and client-registration -> processes inside it), plus **`proxy-init`** as an init container. Shipwright -> **BuildRun** pods may still appear as `Completed` with a different ready count. +> **Note:** The agent pod shows **2/2** — the agent container plus the +> AuthBridge sidecar. In envoy-sidecar mode you'll also see a +> `proxy-init` init container that exits after setting up iptables. +> Shipwright **BuildRun** pods may still appear as `Completed` with a +> different ready count. ### Verify injected containers ```bash -kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent -o jsonpath='{.items[0].spec.containers[*].name}' +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' ``` -Expected — **legacy** (three sidecars): +Expected (proxy-sidecar mode, the cluster default): ``` -agent kagenti-client-registration envoy-proxy spiffe-helper +agent authbridge-proxy ``` -Expected — **combined** (`combinedSidecar: true`): +Or, in envoy-sidecar mode: ``` -agent authbridge +agent envoy-proxy ``` -### Check client registration +### Check operator-managed client registration -**Legacy** — logs are in the client-registration sidecar: +After kagenti-operator#361 client registration runs in the operator +(outside the workload pod). Verify the resulting Secret is mounted +into the agent's sidecar: ```bash -kubectl logs deployment/git-issue-agent -n team1 -c kagenti-client-registration +kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.volumes[?(@.secret)].secret.secretName}' +# Expect a Secret name starting with: kagenti-keycloak-client-credentials- ``` -**Combined** — use the `authbridge` container (client-registration runs inside it): +Follow the operator-side reconciler: ```bash -kubectl logs deployment/git-issue-agent -n team1 -c authbridge --tail=200 +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 ``` -Expected (same messages; search for “Client registration” / `SPIFFE` if the stream is busy): +Expected (operator log lines, exact format depends on the operator's +log format): ``` -SPIFFE credentials ready! -Client ID (SPIFFE ID): spiffe://localtest.me/ns/team1/sa/git-issue-agent -Created Keycloak client "spiffe://localtest.me/ns/team1/sa/git-issue-agent" -Client registration complete! +ClientRegistrationReconciler: ensured Keycloak client + spiffe://localtest.me/ns/team1/sa/git-issue-agent +ClientRegistrationReconciler: wrote Secret + kagenti-keycloak-client-credentials- ``` ### Check agent logs @@ -477,10 +475,11 @@ INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) > **These warnings are expected and harmless.** The agent's built-in auth code > probes for SVID and client-secret files at startup. With AuthBridge, these files -> are used by the sidecars (spiffe-helper, client-registration, Envoy), not by the -> agent container directly. The agent falls back to JWKS-based JWT validation -> (`JWKS_URI is set`), which is the correct behavior — AuthBridge's Envoy sidecar -> handles inbound JWT validation and outbound token exchange on behalf of the agent. +> are produced and consumed inside the AuthBridge sidecar (and the operator's +> ClientRegistrationReconciler), not by the agent container directly. The agent +> falls back to JWKS-based JWT validation (`JWKS_URI is set`), which is the +> correct behavior — AuthBridge handles inbound JWT validation and outbound +> token exchange on behalf of the agent. > These warnings will be removed once the agent's built-in auth logic is cleaned up > ([kagenti/agent-examples#129](https://github.com/kagenti/agent-examples/issues/129)). @@ -743,16 +742,21 @@ exit ### 9e. Verify AuthProxy Logs (Inbound + Outbound) -Check the ext_proc logs to confirm both inbound validation and outbound token -exchange are working. Envoy and authbridge log to the **`envoy-proxy`** container in -legacy mode, or to **`authbridge`** when -[`combinedSidecar`](https://github.com/kagenti/kagenti/blob/main/docs/authbridge/deployment-guide.md) -is enabled — replace `-c envoy-proxy` with `-c authbridge` below. +Check the AuthBridge sidecar logs to confirm both inbound validation and +outbound token exchange are working. The combined sidecar handles both +directions; the container name depends on the resolved AuthBridge mode +(`authbridge-proxy` for proxy-sidecar, `envoy-proxy` for envoy-sidecar). + +```bash +SIDECAR=$(kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' | tr ' ' '\n' \ + | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) +``` **Inbound validation logs:** ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep "\[Inbound\]" +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "\[Inbound\]" ``` Expected: @@ -765,7 +769,7 @@ Expected: **Outbound token exchange logs:** ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep "^2026/" | grep "\[Token Exchange\]" +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 | grep "^2026/" | grep "\[Token Exchange\]" ``` Expected: @@ -1009,8 +1013,12 @@ shape) and never serves ext_proc, so Envoy cannot complete the request. **Diagnose:** ```bash -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy 2>&1 | grep -E "failed to load routes|unmarshal" -kubectl logs deployment/git-issue-agent -n team1 -c authbridge 2>&1 | grep -E "failed to load routes|unmarshal" +# AuthBridge sidecar — name depends on the resolved mode: +SIDECAR=$(kubectl get pod -n team1 -l app.kubernetes.io/name=git-issue-agent \ + -o jsonpath='{.items[0].spec.containers[*].name}' | tr ' ' '\n' \ + | grep -E '^(authbridge-proxy|envoy-proxy)$' | head -1) +kubectl logs deployment/git-issue-agent -n team1 -c "$SIDECAR" 2>&1 \ + | grep -E "failed to load routes|unmarshal" kubectl get configmap authproxy-routes -n team1 -o jsonpath='{.data.routes\.yaml}{"\n"}' ``` @@ -1105,24 +1113,26 @@ kubectl rollout restart deployment/git-issue-agent -n team1 ### Agent Pod Not Starting (not fully ready) -**Symptom:** Pod never reaches the expected ready count — **4/4** (legacy sidecars) or -**2/2** (combined `authbridge` mode). Example: `3/4`, `1/2`, or `CrashLoopBackOff`. +**Symptom:** Pod never reaches **2/2** ready. Example: `1/2` or `CrashLoopBackOff`. -**Fix:** Check logs on the containers that exist. **Legacy** (`combinedSidecar: false`): +**Fix:** Check the agent and the AuthBridge sidecar; if the issue is +operator-managed registration not finishing, the pod waits on +`/shared/client-{id,secret}.txt`. ```bash -kubectl logs deployment/git-issue-agent -n team1 -c kagenti-client-registration -kubectl logs deployment/git-issue-agent -n team1 -c spiffe-helper -kubectl logs deployment/git-issue-agent -n team1 -c envoy-proxy +# AuthBridge sidecar — name depends on resolved mode: +# proxy-sidecar (default): authbridge-proxy +# envoy-sidecar: envoy-proxy +kubectl logs deployment/git-issue-agent -n team1 -c authbridge-proxy kubectl logs deployment/git-issue-agent -n team1 -c agent -``` -**Combined** (`combinedSidecar: true` — single `authbridge` sidecar): - -```bash -kubectl logs deployment/git-issue-agent -n team1 -c authbridge -kubectl logs deployment/git-issue-agent -n team1 -c agent +# In envoy-sidecar mode, the proxy-init init container runs once; +# inspect its log via --previous if it exited with an error: kubectl logs deployment/git-issue-agent -n team1 -c proxy-init --previous 2>/dev/null + +# Operator-managed registration: +kubectl logs -n kagenti-system deployment/kagenti-controller-manager \ + | grep -iE "clientregistration|git-issue-agent" | tail -20 ``` ### Tool MCP Server Unreachable / Connection Reset diff --git a/authbridge/demos/github-issue/demo.md b/authbridge/demos/github-issue/demo.md index 8e29e69a0..ed04005e9 100644 --- a/authbridge/demos/github-issue/demo.md +++ b/authbridge/demos/github-issue/demo.md @@ -39,11 +39,20 @@ produce identical AuthBridge security behavior. └──── Keycloak (token exchange) ─────────┘ ``` -The agent pod includes four containers: +The agent pod has two containers (after kagenti-extensions#411): - **agent** — the A2A agent (port 8000) -- **spiffe-helper** — fetches SPIFFE credentials from SPIRE -- **kagenti-client-registration** — registers the agent with Keycloak -- **envoy-proxy** — intercepts traffic for JWT validation and token exchange +- **AuthBridge sidecar** — combined image; container name depends on + the resolved AuthBridge mode: + - proxy-sidecar (default): `authbridge-proxy` (image: `authbridge`) + - envoy-sidecar: `envoy-proxy` (image: `authbridge-envoy`, plus a + `proxy-init` init container for iptables setup) + +`spiffe-helper` is bundled inside the combined image and gated +per-workload by `SPIRE_ENABLED`. Keycloak client registration is +operator-managed (no in-pod sidecar); the operator's +`ClientRegistrationReconciler` creates a +`kagenti-keycloak-client-credentials-` Secret that the +webhook mounts at `/shared/client-{id,secret}.txt`. ## Key Differences Between Deployment Methods diff --git a/authbridge/demos/github-issue/k8s/git-issue-agent-deployment.yaml b/authbridge/demos/github-issue/k8s/git-issue-agent-deployment.yaml index 6ccd341b8..c40901948 100644 --- a/authbridge/demos/github-issue/k8s/git-issue-agent-deployment.yaml +++ b/authbridge/demos/github-issue/k8s/git-issue-agent-deployment.yaml @@ -1,12 +1,20 @@ # GitHub Issue Agent Deployment with AuthBridge # -# This deployment uses the kagenti-operator webhook to automatically inject AuthBridge sidecars: -# - proxy-init (init container for iptables setup) -# - spiffe-helper (SPIFFE credential fetcher) - only with kagenti.io/spire: enabled -# - kagenti-client-registration (registers agent with Keycloak using SPIFFE ID) -# - envoy-proxy (intercepts inbound traffic for JWT validation, -# outbound HTTP traffic for token exchange, -# outbound HTTPS traffic passed through as-is) +# This deployment uses the kagenti-operator webhook to automatically inject +# a single combined AuthBridge sidecar (post-kagenti-extensions#411). The exact +# container shape depends on the resolved AuthBridge mode: +# - proxy-sidecar (default): one container "authbridge-proxy" from the +# "authbridge" image. spiffe-helper is bundled inside; gated per-workload +# by SPIRE_ENABLED. +# - envoy-sidecar: a "proxy-init" init container (iptables) plus one +# container "envoy-proxy" from the "authbridge-envoy" image (Envoy + +# ext_proc + bundled spiffe-helper). +# Either way the sidecar handles inbound JWT validation, outbound token +# exchange (HTTP), and HTTPS passthrough. +# +# Keycloak client registration is operator-managed (no in-pod sidecar); +# the operator creates a kagenti-keycloak-client-credentials- Secret +# and the webhook mounts it at /shared/client-{id,secret}.txt. # # The agent container: # - Runs the A2A GitHub Issue Agent on port 8000 From 0416172492dbca6a405a1668adde6676db05ca2e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 20:09:11 -0400 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20drop=20webhook=20demo=20+=20col?= =?UTF-8?q?lapse=20authproxy=20=E2=86=92=20proxy-init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combined cleanup that eliminates the last remnants of the pre-#411 multi-sidecar architecture from the repo. Three coordinated moves: 1. Delete the webhook demo The webhook demo was a synthetic-workload demonstration of "the webhook injects sidecars" — but post-#411, webhook injection is the only path; every demo uses it implicitly. Its USP is gone, and its images (auth-proxy:latest, demo-app:latest) were dropped from CI long ago — anyone running it today hits ImagePullBackOff unless they rebuild locally. The same coverage (inbound validation + outbound token exchange) is provided by the weather-agent advanced and github-issue demos, which use real workloads. Drop: - authbridge/demos/webhook/ (entire dir): README.md, k8s/, setup_keycloak.py 2. Cascade the auth-proxy + demo-app source With the webhook demo gone, nothing else references the auth-proxy or demo-app images, so the source can go too: - authbridge/authproxy/main.go + Dockerfile (auth-proxy app source) - authbridge/authproxy/k8s/auth-proxy-deployment.yaml (already broken standalone deployment) - authbridge/authproxy/quickstart/ (entire subtree — README.md, setup_keycloak.py, requirements.txt, k8s/demo-app-deployment.yaml, demo-app/main.go + Dockerfile) - authbridge/authproxy/go.mod + go.sum (no Go code remains) - authbridge/authproxy/Makefile build/deploy/test targets that reference any of the above 3. Rename authproxy/ → proxy-init/ What's left in authbridge/authproxy/ after the cascade is exactly the iptables init container and nothing else (init-iptables.sh + Dockerfile.init + a trimmed Makefile + a rewritten README focused on just the init container's role). The directory name authproxy no longer matches what's inside, and the pre-commit hooks go-fmt-authproxy / go-vet-authproxy were already orphaned with no .go files to scan. Rename to proxy-init/ — matches the image name and the container name the operator injects. Cross-reference sweep across 14 supporting files: * `local-build-and-test.sh` — path updated. * `Makefile` (root) — fmt now iterates over the live Go modules; build-images target replaced with build-proxy-init. * `.pre-commit-config.yaml` — drop the orphaned go-fmt-authproxy / go-vet-authproxy hooks. * `.github/workflows/ci.yaml` — drop the dedicated `go-ci` job (no Go module to test); update the pre-commit job's go-version-file to reference authlib/go.mod; rewrite the GOWORK="off" comment that named the deleted authproxy module. * `.github/workflows/build.yaml` — proxy-init context path bumped. * `.github/workflows/security-scans.yaml` — drop the authbridge/proxy-init/quickstart skip-dir entry (path is gone). * `.github/dependabot.yml` — rewrite. Drop entries for the deleted authproxy/quickstart/demo-app docker image, the now-Go-less authproxy go.mod, the never-existed authproxy/client-registration pip + docker entries. Add the four authlib + cmd/* go.mod entries that were missing entirely. * `authbridge/go.work` — drop ./authproxy from the workspace. * `authbridge/CLAUDE.md` — directory tree, demo descriptions, Keycloak setup-script table, build/deploy section, and the envoy-config + required-ConfigMaps sections all rewritten to reference the kagenti Helm chart's templates instead of the deleted demos/webhook/k8s/configmaps-webhook.yaml. Gotcha #5 (virtualenv in deleted quickstart) removed; remaining gotchas renumbered. * `CLAUDE.md` (root) — directory tree updated; ci.yaml description no longer mentions authproxy as a separate Go target; manual-deployment learning-path bullet retargeted at the routes-config doc. * `authbridge/README.md` — the "AuthProxy" link bullets that described an Envoy + ext_proc binary (which doesn't exist anymore — that's authbridge-envoy now) replaced with bullets describing each cmd/* binary plus proxy-init. * `README.md` (root) — same overview rewrite; broken ./authbridge/client-registration/ link removed. * `LOCAL_TESTING_GUIDE.md` — Webhook Demo bullet retargeted at the token-exchange-routes guide; deprecation banner alternatives list updated to point at github-issue instead of the deleted webhook demo. * `authbridge/demos/README.md` — Webhook row + description block removed from the demos table. * `authbridge/demos/token-exchange-routes/README.md` — two broken `../webhook/` links retargeted at github-issue. * `authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml` — the comment line that pointed at the deleted webhook README for the type-label rule retargeted at the operator's webhook source. Net diff: 23 files modified, 14 deleted, 1 dir renamed (1 file moved via git mv). About -1,800 LOC of unmaintained, broken-image-dependent content; +120 LOC of corrected prose and the trimmed proxy-init Makefile/README. End-state directory layout: each subdirectory under authbridge/ has a name that matches what's inside, no orphaned hooks, no broken cross-references, no dead-image references. Verified: `bash -n local-build-and-test.sh`; `kubectl apply --dry-run=client` on the surviving demo YAMLs; `helm template` on the kagenti chart still renders (confirms our path changes don't break anyone consuming the chart); repo-wide grep for `authbridge/authproxy` returns zero hits in tracked content; broken-link grep on demos/webhook/ matches only inside the LOCAL_TESTING_GUIDE.md deprecation banner that was already flagged as stale earlier in this PR. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .github/dependabot.yml | 37 +- .github/workflows/build.yaml | 2 +- .github/workflows/ci.yaml | 34 +- .github/workflows/security-scans.yaml | 2 +- .pre-commit-config.yaml | 14 - CLAUDE.md | 28 +- CONTRIBUTING.md | 2 +- LOCAL_TESTING_GUIDE.md | 5 +- Makefile | 12 +- README.md | 10 +- authbridge/CLAUDE.md | 112 ++--- authbridge/README.md | 4 +- authbridge/authproxy/Dockerfile | 29 -- authbridge/authproxy/Makefile | 64 --- authbridge/authproxy/README.md | 298 ------------ authbridge/authproxy/go.mod | 21 - authbridge/authproxy/go.sum | 36 -- .../authproxy/k8s/auth-proxy-deployment.yaml | 298 ------------ authbridge/authproxy/main.go | 113 ----- authbridge/authproxy/quickstart/README.md | 239 ---------- .../authproxy/quickstart/demo-app/Dockerfile | 32 -- .../authproxy/quickstart/demo-app/main.go | 252 ---------- .../quickstart/k8s/demo-app-deployment.yaml | 55 --- .../authproxy/quickstart/requirements.txt | 1 - .../authproxy/quickstart/setup_keycloak.py | 194 -------- authbridge/demos/README.md | 7 - .../demos/token-exchange-routes/README.md | 9 +- .../k8s/weather-tool-advanced.yaml | 5 +- authbridge/demos/webhook/README.md | 450 ------------------ .../agent-deployment-webhook-no-spiffe.yaml | 95 ---- .../webhook/k8s/agent-deployment-webhook.yaml | 95 ---- .../k8s/auth-target-deployment-webhook.yaml | 67 --- .../demos/webhook/k8s/configmaps-webhook.yaml | 244 ---------- authbridge/demos/webhook/setup_keycloak.py | 442 ----------------- authbridge/go.work | 1 - .../{authproxy => proxy-init}/Dockerfile.init | 0 authbridge/proxy-init/Makefile | 18 + authbridge/proxy-init/README.md | 73 +++ .../init-iptables.sh | 0 local-build-and-test.sh | 2 +- 40 files changed, 212 insertions(+), 3190 deletions(-) delete mode 100644 authbridge/authproxy/Dockerfile delete mode 100644 authbridge/authproxy/Makefile delete mode 100644 authbridge/authproxy/README.md delete mode 100644 authbridge/authproxy/go.mod delete mode 100644 authbridge/authproxy/go.sum delete mode 100644 authbridge/authproxy/k8s/auth-proxy-deployment.yaml delete mode 100644 authbridge/authproxy/main.go delete mode 100644 authbridge/authproxy/quickstart/README.md delete mode 100644 authbridge/authproxy/quickstart/demo-app/Dockerfile delete mode 100644 authbridge/authproxy/quickstart/demo-app/main.go delete mode 100644 authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml delete mode 100644 authbridge/authproxy/quickstart/requirements.txt delete mode 100644 authbridge/authproxy/quickstart/setup_keycloak.py delete mode 100644 authbridge/demos/webhook/README.md delete mode 100644 authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml delete mode 100644 authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml delete mode 100644 authbridge/demos/webhook/k8s/auth-target-deployment-webhook.yaml delete mode 100644 authbridge/demos/webhook/k8s/configmaps-webhook.yaml delete mode 100644 authbridge/demos/webhook/setup_keycloak.py rename authbridge/{authproxy => proxy-init}/Dockerfile.init (100%) create mode 100644 authbridge/proxy-init/Makefile create mode 100644 authbridge/proxy-init/README.md rename authbridge/{authproxy => proxy-init}/init-iptables.sh (100%) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7f60bea95..dcd5bebf9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,38 +6,41 @@ updates: schedule: interval: weekly - # Go - AuthProxy + # Go - authlib (the shared library — pulled in by every cmd/* binary + # via replace directives, so updates here flow into everything). - package-ecosystem: gomod - directory: /authbridge/authproxy + directory: /authbridge/authlib schedule: interval: weekly - # Python - root (tests) - - package-ecosystem: pip - directory: / + # Go - mode-specific binaries + - package-ecosystem: gomod + directory: /authbridge/cmd/authbridge-proxy schedule: interval: weekly - - # Python - client-registration - - package-ecosystem: pip - directory: /authbridge/client-registration + - package-ecosystem: gomod + directory: /authbridge/cmd/authbridge-envoy + schedule: + interval: weekly + - package-ecosystem: gomod + directory: /authbridge/cmd/authbridge-lite schedule: interval: weekly - # Docker - AuthProxy - - package-ecosystem: docker - directory: /authbridge/authproxy + # Go - abctl TUI + - package-ecosystem: gomod + directory: /authbridge/cmd/abctl schedule: interval: weekly - # Docker - AuthProxy quickstart demo-app - - package-ecosystem: docker - directory: /authbridge/authproxy/quickstart/demo-app + # Python - root (tests) + - package-ecosystem: pip + directory: / schedule: interval: weekly - # Docker - client-registration + # Docker - proxy-init (iptables init container, envoy-sidecar mode) - package-ecosystem: docker - directory: /authbridge/client-registration + directory: /authbridge/proxy-init schedule: interval: weekly diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9c290641b..64b82d4ee 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,7 +26,7 @@ jobs: # set up traffic redirection. Proxy-sidecar mode does not # need this (HTTP_PROXY env var routing replaces iptables). - name: proxy-init - context: ./authbridge/authproxy + context: ./authbridge/proxy-init dockerfile: Dockerfile.init # AuthBridge envoy-sidecar combined image — diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 016e35887..4f0c6138c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version-file: authbridge/authproxy/go.mod + go-version-file: authbridge/authlib/go.mod - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: @@ -40,32 +40,6 @@ jobs: env: SKIP: ai-assisted-by-trailer - go-ci: - name: Go CI - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: authbridge/authproxy - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - with: - go-version-file: authbridge/authproxy/go.mod - cache-dependency-path: authbridge/authproxy/go.sum - - - name: Lint - run: | - go fmt ./... - go vet ./... - - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v -race -cover ./... - go-ci-authlib: name: Go CI (authlib) runs-on: ubuntu-latest @@ -107,9 +81,9 @@ jobs: run: working-directory: authbridge/cmd/${{ matrix.binary }} env: - # Disable go.work — the workspace at authbridge/ includes ./authproxy - # which would pull in that module's dependencies. The replace directive - # in go.mod handles the authlib dependency for CI builds. + # Disable go.work so each cmd/* module resolves authlib via its own + # `replace` directive in go.mod (the workspace would otherwise pull + # all sibling modules in and slow down CI). GOWORK: "off" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/.github/workflows/security-scans.yaml b/.github/workflows/security-scans.yaml index 0f49afb79..21358df20 100644 --- a/.github/workflows/security-scans.yaml +++ b/.github/workflows/security-scans.yaml @@ -245,7 +245,7 @@ jobs: scan-type: 'config' scan-ref: '.' severity: 'CRITICAL,HIGH,MEDIUM' - skip-dirs: 'authbridge/demos,authbridge/authproxy/quickstart' + skip-dirs: 'authbridge/demos' exit-code: '0' format: 'table' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e77d9196..1cda21ab0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,17 +30,3 @@ repos: - id: ruff-format files: ^authbridge/ - - repo: local - hooks: - - id: go-fmt-authproxy - name: go-fmt (AuthProxy) - entry: bash -c 'cd authbridge/authproxy && go fmt ./...' - language: system - files: ^authbridge/authproxy/.*\.go$ - pass_filenames: false - - id: go-vet-authproxy - name: go-vet (AuthProxy) - entry: bash -c 'cd authbridge/authproxy && go vet ./...' - language: system - files: ^authbridge/authproxy/.*\.go$ - pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index c850e1739..80906601b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,10 +44,12 @@ kagenti-extensions/ │ │ ├── main.go │ │ ├── Dockerfile # proxy-sidecar lite combined image │ │ └── entrypoint.sh -│ ├── authproxy/ # iptables init container + standalone quickstart -│ │ ├── quickstart/ # Standalone demo (no SPIFFE) -│ │ └── k8s/ # Standalone K8s manifests -│ ├── demos/ # Demo scenarios (weather-agent, github-issue, webhook, token-exchange-routes, mcp-parser) +│ ├── proxy-init/ # iptables init container (envoy-sidecar mode only) +│ │ ├── init-iptables.sh +│ │ ├── Dockerfile.init +│ │ ├── Makefile +│ │ └── README.md +│ ├── demos/ # Demo scenarios (weather-agent, github-issue, token-exchange-routes, mcp-parser) │ └── keycloak_sync.py # Declarative Keycloak sync tool ├── tests/ # Python tests (keycloak_sync) ├── .github/ @@ -74,8 +76,8 @@ kagenti-extensions/ **Common:** - `authlib/` — shared auth library (JWT validation, token exchange, caching, routing, all listener implementations, all plugins). -- `authproxy/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode only. -- `authproxy/Dockerfile.init` — proxy-init container image. +- `proxy-init/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode only. +- `proxy-init/Dockerfile.init` — proxy-init container image. **Ports (envoy-sidecar):** 15123 (outbound), 15124 (inbound), 9090 (ext-proc), 9901 (admin) **Ports (proxy-sidecar / lite):** 8080 (reverse proxy), 8081 (forward proxy), 9091 (health), 9093 (stats), 9094 (session API) @@ -139,7 +141,7 @@ Three mode-specific binaries, one Dockerfile per binary: | Workflow | Trigger | Purpose | |----------|---------|---------| -| `ci.yaml` | PR to main/release-* | Go fmt, vet, build, test for authproxy, authlib, and the cmd/authbridge-* binaries; Python tests | +| `ci.yaml` | PR to main/release-* | Pre-commit, Go fmt/vet/build/test for authlib and the cmd/authbridge-* binaries; Python tests | | `build.yaml` | Tag push (`v*`) or manual | Multi-arch Docker builds for: proxy-init, authbridge (proxy-sidecar combined), authbridge-envoy (envoy-sidecar combined), authbridge-lite (proxy-sidecar lite combined) | | `security-scans.yaml` | PR to main | Dependency review, shellcheck, YAML lint, Hadolint, Bandit, Trivy, CodeQL | | `scorecard.yaml` | Weekly / push to main | OpenSSF Scorecard security health metrics | @@ -165,7 +167,7 @@ All images are pushed to `ghcr.io/kagenti/kagenti-extensions/` from | **`authbridge`** | **`authbridge/cmd/authbridge-proxy/Dockerfile`** | **proxy-sidecar combined image (default mode): authbridge-proxy (full plugin set incl. parsers) + spiffe-helper. No Envoy.** | | `authbridge-envoy` | `authbridge/cmd/authbridge-envoy/Dockerfile` | envoy-sidecar combined image: Envoy + authbridge-envoy (ext_proc, full plugin set) + spiffe-helper | | `authbridge-lite` | `authbridge/cmd/authbridge-lite/Dockerfile` | proxy-sidecar lite combined image: authbridge-lite (auth gates only, parsers dropped) + spiffe-helper. Same listener layout as `authbridge`; not yet referenced by the operator's default config | -| `proxy-init` | `authbridge/authproxy/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) | +| `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) | In all three combined images, `spiffe-helper` is started conditionally based on the `SPIRE_ENABLED` env var (set by the operator when SPIRE @@ -185,7 +187,7 @@ Hooks: - `trailing-whitespace`, `end-of-file-fixer`, `check-added-large-files` (max 1024KB), `check-yaml`, `check-json`, `check-merge-conflict`, `mixed-line-ending` - `ai-assisted-by-trailer` — Rewrites `Co-Authored-By` to `Assisted-By` (commit-msg stage) - `ruff`, `ruff-format` — Python linting/formatting on `authbridge/` files -- `go-fmt`, `go-vet` — Runs on `authbridge/authproxy/` Go files +- `go-fmt`, `go-vet` — Runs on `authbridge/proxy-init/` Go files ## Languages and Tech Stack @@ -229,7 +231,7 @@ When the operator injects sidecars, the target namespace needs these resources: ```bash # AuthProxy images -cd authbridge/authproxy && make build-images +cd authbridge/proxy-init && make build-images # Client registration (no separate build needed, uses Dockerfile directly) ``` @@ -241,7 +243,7 @@ cd authbridge/authproxy && make build-images 3. See the [AuthBridge demos index](authbridge/demos/README.md) for a recommended learning path: - **Getting started**: `authbridge/demos/weather-agent/demo-ui.md` (inbound validation, UI deployment) - **Full flow**: `authbridge/demos/github-issue/demo-ui.md` (token exchange + scope-based access) - - **Webhook internals**: `authbridge/demos/webhook/README.md` + - **Routes config reference**: `authbridge/demos/token-exchange-routes/README.md` - **Routes config reference**: `authbridge/demos/token-exchange-routes/README.md` (single + multi-target route patterns) ### Adding a New Component Image to CI @@ -282,9 +284,9 @@ cd authbridge/authproxy && make build-images ## Gotchas and Known Issues -1. **One Go module:** The repo has a single Go module at `authbridge/authproxy/go.mod` (Go 1.24). +1. **One Go module:** The repo has a single Go module at `authbridge/proxy-init/go.mod` (Go 1.24). -2. **Avoid committing venvs:** Virtual environment directories (e.g. `authbridge/authproxy/quickstart/venv/`) should be gitignored (the repo's `.gitignore` has a `venv` pattern). Do not create and commit new virtual environments under version control. +2. **Avoid committing venvs:** Virtual environment directories (e.g. `authbridge/proxy-init/quickstart/venv/`) should be gitignored (the repo's `.gitignore` has a `venv` pattern). Do not create and commit new virtual environments under version control. 3. **Envoy config not embedded:** The envoy-proxy sidecar mounts `envoy-config` ConfigMap at `/etc/envoy`. This ConfigMap must exist in the target namespace before workloads are created. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba3ae9cd1..441f5ead0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ cd kagenti-extensions pre-commit install # Build AuthProxy images -cd authbridge/authproxy && make build-images +cd authbridge/proxy-init && make build-images ``` ## Issues diff --git a/LOCAL_TESTING_GUIDE.md b/LOCAL_TESTING_GUIDE.md index 5cd679755..d92f7980d 100644 --- a/LOCAL_TESTING_GUIDE.md +++ b/LOCAL_TESTING_GUIDE.md @@ -16,7 +16,8 @@ > **Working alternatives in the meantime**: > - `authbridge/demos/weather-agent/demo-ui-advanced.md` — current > reference for the combined-sidecar flow with SPIFFE. -> - `authbridge/demos/webhook/README.md` — webhook-injected demo. +> - `authbridge/demos/github-issue/demo-ui.md` — full token-exchange +> flow with scope-based access control. This guide walks you through testing JWT-SVID authentication using local images (no push to ghcr.io). @@ -470,7 +471,7 @@ For testing the complete AuthBridge flow with automatic sidecar injection: **Manual Demo:** Follow [authbridge/demos/github-issue/demo-manual.md](authbridge/demos/github-issue/demo-manual.md) -**Webhook Demo:** Follow [authbridge/demos/webhook/README.md](authbridge/demos/webhook/README.md) +**Token-Exchange Routes:** Follow [authbridge/demos/token-exchange-routes/README.md](authbridge/demos/token-exchange-routes/README.md) --- diff --git a/Makefile b/Makefile index 427370416..17110ed9e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Root Makefile for kagenti-extensions monorepo # Orchestrates linting and formatting across all sub-projects -.PHONY: lint fmt pre-commit build-images help +.PHONY: lint fmt pre-commit build-proxy-init help help: ## Display this help @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) @@ -12,7 +12,11 @@ lint: ## Run all linters (pre-commit hooks) pre-commit run --all-files fmt: ## Run formatters across all sub-projects - cd authbridge/authproxy && go fmt ./... + cd authbridge/authlib && go fmt ./... + cd authbridge/cmd/abctl && go fmt ./... + cd authbridge/cmd/authbridge-proxy && go fmt ./... + cd authbridge/cmd/authbridge-envoy && go fmt ./... + cd authbridge/cmd/authbridge-lite && go fmt ./... ruff format authbridge/ pre-commit: ## Install pre-commit hooks (including commit-msg) @@ -20,5 +24,5 @@ pre-commit: ## Install pre-commit hooks (including commit-msg) ##@ Sub-project Targets -build-images: ## Build AuthProxy Docker images - cd authbridge/authproxy && make build-images +build-proxy-init: ## Build the proxy-init iptables init container + cd authbridge/proxy-init && make docker-build-init diff --git a/README.md b/README.md index 95990dab5..bbb14b44a 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,16 @@ Kubernetes security extensions for the [Kagenti](https://github.com/kagenti/kage [AuthBridge](./authbridge/) provides end-to-end authentication for Kubernetes workloads with [SPIFFE/SPIRE](https://spiffe.io) integration. It consists of: -- **[AuthProxy](./authbridge/authproxy/)** — Envoy proxy with a gRPC external processor for inbound JWT validation and outbound OAuth 2.0 token exchange (RFC 8693). Enables secure service-to-service communication by transparently intercepting traffic. -- **[Client Registration](./authbridge/client-registration/)** — Automatically registers Kubernetes workloads as Keycloak OAuth2 clients using their SPIFFE identity, eliminating manual client configuration and static credentials. +- **[Authlib](./authbridge/authlib/)** — shared Go library: JWT validation, RFC 8693 token exchange, plugin pipeline, listener implementations. +- **Mode-specific binaries** under [`authbridge/cmd/`](./authbridge/cmd/): + - [`authbridge-proxy`](./authbridge/cmd/authbridge-proxy/) — proxy-sidecar (default): HTTP forward + reverse proxies, full plugin set. + - [`authbridge-envoy`](./authbridge/cmd/authbridge-envoy/) — envoy-sidecar: ext_proc gRPC server hooked into Envoy, full plugin set. + - [`authbridge-lite`](./authbridge/cmd/authbridge-lite/) — proxy-sidecar with auth-only plugins (no parsers); for size-constrained deployments. +- **[proxy-init](./authbridge/proxy-init/)** — iptables init container used by envoy-sidecar mode for transparent traffic interception. - **[Keycloak Sync](./authbridge/keycloak_sync.py)** — Declarative tool for synchronizing Keycloak configuration. +Keycloak client registration runs in the [kagenti-operator](https://github.com/kagenti/kagenti-operator) (separate repo, post-#411 / kagenti-operator#361 — no in-pod registration sidecar). + See the [AuthBridge README](./authbridge/README.md) for architecture details and the [demos index](./authbridge/demos/README.md) for getting started. ## Container Images diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index 6f100702a..87d16d52c 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -62,13 +62,11 @@ authbridge/ │ ├── Dockerfile # proxy-sidecar lite combined image │ └── entrypoint.sh │ -├── authproxy/ # iptables init container + standalone quickstart -│ ├── init-iptables.sh # iptables setup for envoy-sidecar mode +├── proxy-init/ # iptables init container (envoy-sidecar mode only) +│ ├── init-iptables.sh # iptables setup script │ ├── Dockerfile.init # proxy-init container image -│ ├── k8s/ # Standalone K8s manifests -│ └── quickstart/ # Standalone demo (no SPIFFE) -│ ├── setup_keycloak.py -│ └── demo-app/main.go # Test target: JWT validation (:8081), TLS echo (:8443) +│ ├── Makefile # docker-build-init + load-image targets +│ └── README.md │ ├── demos/ # Demo scenarios with full setup │ ├── README.md # Demo index (recommended starting order) @@ -187,7 +185,7 @@ Declarative Keycloak synchronization tool that maintains client scope mappings b ### Envoy Configuration -Envoy config lives in `demos/webhook/k8s/configmaps-webhook.yaml` (the `envoy-config` ConfigMap). Key listeners: `outbound_listener` (15123), `inbound_listener` (15124). Inbound listener injects `x-authbridge-direction: inbound` header. Both use ext_proc cluster pointing to the authbridge binary on localhost:9090. +Envoy config lives in the `envoy-config` ConfigMap rendered by the [kagenti Helm chart](https://github.com/kagenti/kagenti) at install time (template: `charts/kagenti/templates/agent-namespaces.yaml` / `authbridge-template-configmaps.yaml`). Key listeners: `outbound_listener` (15123), `inbound_listener` (15124). Inbound listener injects `x-authbridge-direction: inbound` header. Both use ext_proc cluster pointing to the authbridge binary on localhost:9090. ## Demo Scenarios @@ -201,13 +199,12 @@ The `demos/` directory contains the following scenarios (see `demos/README.md` f ## Keycloak Setup Scripts -There are **three** setup scripts for different demo scenarios: +There are **two** setup scripts for different demo scenarios: | Script | Location | Use Case | |--------|----------|----------| -| `setup_keycloak.py` | `authbridge/demos/webhook/` | Webhook-injected deployments (parameterized namespace/SA, creates realm, auth-target client, agent-spiffe-aud + auth-target-aud scopes, alice user) | +| `setup_keycloak_weather_advanced.py` | `authbridge/demos/weather-agent/` | Weather agent (advanced) demo: realm setup, scopes for token exchange to the weather tool's audience, alice user. Drives the CI verify script `deploy_and_verify_advanced.sh`. | | `setup_keycloak.py` | `authbridge/demos/github-issue/` | GitHub issue integration demo (creates github-tool client, github-tool-aud + github-full-access scopes, alice + bob users) | -| `setup_keycloak.py` | `authbridge/authproxy/quickstart/` | Standalone AuthProxy quickstart without SPIFFE (creates application-caller, authproxy, demoapp clients with per-client scope assignment) | **Common Keycloak defaults across all scripts:** - URL: `http://keycloak.localtest.me:8080` @@ -218,7 +215,7 @@ There are **three** setup scripts for different demo scenarios: ## Required ConfigMaps for Webhook Injection -When the webhook injects sidecars (via [kagenti-operator](https://github.com/kagenti/kagenti-operator)), these ConfigMaps must exist in the target namespace. All required ones are defined in `demos/webhook/k8s/configmaps-webhook.yaml`: +When the webhook injects sidecars (via [kagenti-operator](https://github.com/kagenti/kagenti-operator)), these ConfigMaps must exist in the target namespace. The kagenti Helm chart's `agent-namespaces.yaml` and `authbridge-template-configmaps.yaml` templates render them; the operator copies them into agent namespaces that don't already have them: | Resource | Kind | Consumer | Key Fields | |----------|------|----------|------------| @@ -253,55 +250,43 @@ Sidecars communicate through files on shared volumes: ## Build and Deploy -### AuthProxy (standalone quickstart, no webhook) +### Build images locally ```bash -cd authbridge/authproxy - -# Build demo images (auth-proxy, demo-app, proxy-init) -make build-images - -# Load into Kind cluster -make load-images # Uses KIND_CLUSTER_NAME env var (default: kagenti) - -# Build the combined sidecar images (from authbridge/ context). -# Pick the one matching your deployment mode: -# -# authbridge = proxy-sidecar combined (authbridge-proxy + spiffe-helper, full plugins) -# authbridge-envoy = envoy-sidecar combined (Envoy + authbridge-envoy ext_proc + spiffe-helper) -# authbridge-lite = proxy-sidecar lite (authbridge-lite + spiffe-helper, no parsers) -cd .. && podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . -podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . -podman build -f cmd/authbridge-lite/Dockerfile -t authbridge-lite:latest . +# Build the proxy-init iptables init container (envoy-sidecar mode only) +cd authbridge/proxy-init +make docker-build-init +make load-image # Uses KIND_CLUSTER_NAME env var (default: kagenti) + +# Build the combined sidecar images from the authbridge/ context. +# Pick whichever you need; the operator selects the image per workload +# from the resolved AuthBridge mode (see kagenti-operator#361). +cd .. +podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . # proxy-sidecar (default) +podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . # envoy-sidecar +podman build -f cmd/authbridge-lite/Dockerfile -t authbridge-lite:latest . # proxy-sidecar lite +kind load docker-image authbridge:latest --name kagenti kind load docker-image authbridge-envoy:latest --name kagenti -kind load docker-image authbridge:latest --name kagenti -kind load docker-image authbridge-lite:latest --name kagenti +kind load docker-image authbridge-lite:latest --name kagenti +``` -# Deploy auth-proxy + demo-app -make deploy +For the repo-level "build everything" path, the root `local-build-and-test.sh` orchestrates all four images plus the kagenti-side `spiffe-idp-setup`. -# Clean up -make undeploy -``` +### Full demo with webhook injection -### Full Demo with Webhook +The recommended end-to-end flow uses the weather-agent advanced demo, +which exercises the post-#411 combined sidecar shape with token +exchange to a tool's audience: ```bash -# 1. Setup Keycloak (requires port-forward to Keycloak) -cd authbridge/demos/webhook -pip install -r ../../requirements.txt -python setup_keycloak.py # Creates realm, auth-target client, scopes, alice user - -# 2. Apply ConfigMaps to target namespace -kubectl apply -f k8s/configmaps-webhook.yaml -n - -# 3. Deploy workloads (webhook auto-injects sidecars) -kubectl apply -f k8s/agent-deployment-webhook.yaml # With SPIFFE -# or -kubectl apply -f k8s/agent-deployment-webhook-no-spiffe.yaml # Without SPIFFE -kubectl apply -f k8s/auth-target-deployment-webhook.yaml # Target service +# Apply manifests, run Keycloak setup, verify end-to-end +authbridge/demos/weather-agent/deploy_and_verify_advanced.sh ``` +For an interactive walkthrough see +`authbridge/demos/weather-agent/demo-ui-advanced.md`. For route +configuration see `authbridge/demos/token-exchange-routes/README.md`. + ## Important Port Mapping | Port | Component | Protocol | Purpose | @@ -312,9 +297,6 @@ kubectl apply -f k8s/auth-target-deployment-webhook.yaml # Target service | 9093 | authbridge | HTTP | Stats + config inspection (`/stats`, `/config`, `/reload/status`) | | 9094 | authbridge | HTTP | Session events API (JSON snapshots + SSE stream) | | 9901 | Envoy | HTTP | Admin interface (bound to 127.0.0.1) | -| 8080 | auth-proxy | HTTP | Example app (NOT part of sidecar) | -| 8081 | demo-app | HTTP | Demo target (JWT validation) | -| 8443 | demo-app | HTTPS | Demo target (TLS echo, no JWT) | ## Session Events API (`:9094`) @@ -460,9 +442,11 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h - All scripts use `python-keycloak` library (KeycloakAdmin class) ### Changing Envoy Configuration -- Edit the `envoy.yaml` section in `demos/webhook/k8s/configmaps-webhook.yaml` (or the appropriate demo's configmaps file) +- Edit the `envoy.yaml` template in the [kagenti Helm chart](https://github.com/kagenti/kagenti) + (`charts/kagenti/templates/agent-namespaces.yaml` or + `authbridge-template-configmaps.yaml`) and `helm upgrade` - Key listener/cluster names: `outbound_listener`, `inbound_listener`, `original_destination`, `ext_proc_cluster` -- After changes, re-apply the ConfigMap and restart pods +- After changes, restart the affected pods so they pick up the new ConfigMap content ## Gotchas and Known Issues @@ -474,19 +458,21 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h 4. **TLS passthrough is one-way**: Outbound HTTPS traffic passes through Envoy without token exchange via the TLS passthrough filter chain. Only plaintext HTTP outbound traffic reaches authbridge. With the default outbound policy of `"passthrough"`, even plaintext HTTP traffic is forwarded unchanged unless it matches an explicit route in `authproxy-routes`. -5. **Virtualenv directory**: For local development you may create `authproxy/quickstart/venv/`, but it should be gitignored and is not committed to the repo. - -6. **Admin credentials in ConfigMap**: `demos/webhook/k8s/configmaps-webhook.yaml` stores Keycloak admin credentials in a ConfigMap (not a Secret). This is for demo only -- production should use Kubernetes Secrets. +5. **Admin credentials in ConfigMap**: the kagenti Helm chart's + `agent-namespaces.yaml` template stores Keycloak admin credentials + in `authbridge-config` (a ConfigMap, not a Secret). This is for + demo / dev clusters only — production should use a Kubernetes + Secret and mount via SecretKeyRef. -7. **Envoy Lua filter required for inbound**: The `x-authbridge-direction: inbound` header MUST be injected via a Lua filter before the ext_proc filter in the inbound listener. Route-level `request_headers_to_add` does NOT work because the router filter runs after ext_proc. +6. **Envoy Lua filter required for inbound**: The `x-authbridge-direction: inbound` header MUST be injected via a Lua filter before the ext_proc filter in the inbound listener. Route-level `request_headers_to_add` does NOT work because the router filter runs after ext_proc. -8. **iptables backend auto-detection**: `init-iptables.sh` auto-detects `iptables-legacy` vs `iptables-nft`. Override with `IPTABLES_CMD` env var if needed. Always verify with proxy-init logs after deployment. +7. **iptables backend auto-detection**: `init-iptables.sh` auto-detects `iptables-legacy` vs `iptables-nft`. Override with `IPTABLES_CMD` env var if needed. Always verify with proxy-init logs after deployment. -9. **Route host patterns must match HTTP Host header**: The `host` field in `authproxy-routes` is matched against the HTTP `Host` header, which is set by the HTTP client from the URL hostname. For in-cluster calls, this is the **short Kubernetes service name** from `MCP_URL` (e.g., `github-tool-mcp`), not the FQDN. Using the wrong pattern (e.g., `*.github-issue-tool*.svc.cluster.local`) will silently fall through to the default passthrough policy. +8. **Route host patterns must match HTTP Host header**: The `host` field in `authproxy-routes` is matched against the HTTP `Host` header, which is set by the HTTP client from the URL hostname. For in-cluster calls, this is the **short Kubernetes service name** from `MCP_URL` (e.g., `github-tool-mcp`), not the FQDN. Using the wrong pattern (e.g., `*.github-issue-tool*.svc.cluster.local`) will silently fall through to the default passthrough policy. -10. **Keycloak scope assignment for dynamically registered clients**: When `client-registration` auto-registers an agent as a Keycloak client, the client may not inherit all necessary scopes. The agent's own audience scope (e.g., `agent-team1-git-issue-agent-aud`) must be a **default** client scope for inbound JWT audience validation to work. Token exchange scopes (e.g., `github-tool-aud`, `github-full-access`) must be **optional** client scopes for `client_credentials` grants with explicit `scope=` to succeed. Re-run the demo's `setup_keycloak.py` after the agent is deployed to assign these scopes to the registered client. +9. **Keycloak scope assignment for dynamically registered clients**: When the operator's `ClientRegistrationReconciler` auto-registers an agent as a Keycloak client, the client may not inherit all necessary scopes. The agent's own audience scope (e.g., `agent-team1-git-issue-agent-aud`) must be a **default** client scope for inbound JWT audience validation to work. Token exchange scopes (e.g., `github-tool-aud`, `github-full-access`) must be **optional** client scopes for `client_credentials` grants with explicit `scope=` to succeed. Re-run the demo's `setup_keycloak.py` after the agent is deployed to assign these scopes to the registered client. -11. **Outbound passthrough is the safe default**: The `DEFAULT_OUTBOUND_POLICY` defaults to `passthrough`, which means outbound traffic to LLM inference endpoints (e.g., Ollama via `host.docker.internal`) passes through without token exchange. If this were set to `exchange`, all outbound HTTP calls would attempt token exchange and fail for non-Keycloak destinations. +10. **Outbound passthrough is the safe default**: The `DEFAULT_OUTBOUND_POLICY` defaults to `passthrough`, which means outbound traffic to LLM inference endpoints (e.g., Ollama via `host.docker.internal`) passes through without token exchange. If this were set to `exchange`, all outbound HTTP calls would attempt token exchange and fail for non-Keycloak destinations. ## DCO Sign-Off (Mandatory) diff --git a/authbridge/README.md b/authbridge/README.md index 023d97d8b..1cbc98051 100644 --- a/authbridge/README.md +++ b/authbridge/README.md @@ -2,7 +2,7 @@ AuthBridge provides **secure, transparent token management** for Kubernetes workloads. It combines automatic [client registration](./client-registration/) with [token exchange](./authproxy/) capabilities, enabling zero-trust authentication flows with [SPIFFE/SPIRE](https://spiffe.io) integration. -> **📘 Looking to run the demo?** See the [Weather Agent](./demos/weather-agent/demo-ui.md) or [Webhook](./demos/webhook/README.md) demos for step-by-step instructions, and [Token-Exchange Routes](./demos/token-exchange-routes/README.md) for route configuration. +> **📘 Looking to run the demo?** See the [Weather Agent](./demos/weather-agent/demo-ui.md) or [GitHub Issue Agent](./demos/github-issue/demo.md) demos for step-by-step instructions, and [Token-Exchange Routes](./demos/token-exchange-routes/README.md) for route configuration. ## Deployment Modes @@ -345,7 +345,7 @@ The easiest way to get all prerequisites is to use the [Kagenti Ansible installe ### Demos -- **[Webhook Demo](./demos/webhook/README.md)** - Shows how the [kagenti-operator](https://github.com/kagenti/kagenti-operator) webhook automatically injects AuthBridge sidecars into your deployments (recommended starting point) +- **[Weather Agent Demo](./demos/weather-agent/demo-ui.md)** - Recommended starting demo: shows how the [kagenti-operator](https://github.com/kagenti/kagenti-operator) webhook automatically injects the combined AuthBridge sidecar, with inbound JWT validation and outbound passthrough - **[GitHub Issue Agent Demo](./demos/github-issue/demo.md)** - End-to-end demo with the real GitHub Issue Agent and GitHub MCP Tool, showing transparent token exchange via AuthBridge - [Manual deployment](./demos/github-issue/demo-manual.md) — deploy everything via `kubectl` and YAML manifests - [UI deployment](./demos/github-issue/demo-ui.md) — import agent and tool via the Kagenti dashboard diff --git a/authbridge/authproxy/Dockerfile b/authbridge/authproxy/Dockerfile deleted file mode 100644 index d6b0d5a1e..000000000 --- a/authbridge/authproxy/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM golang:1.26-alpine AS builder - -WORKDIR /app - -# Copy go mod and go sum files -COPY go.mod go.sum ./ - -# Download dependencies (go.sum will be created automatically if missing) -RUN go mod download - -# Copy source code -COPY . . - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o auth-proxy . - -# Final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates - -WORKDIR /root/ - -# Copy the binary from builder stage -COPY --from=builder /app/auth-proxy . - -EXPOSE 8080 - -CMD ["./auth-proxy"] diff --git a/authbridge/authproxy/Makefile b/authbridge/authproxy/Makefile deleted file mode 100644 index 0e7181b4d..000000000 --- a/authbridge/authproxy/Makefile +++ /dev/null @@ -1,64 +0,0 @@ -.PHONY: dev clean build build-images run-proxy run-target test unit-test docker-build-proxy docker-build-target docker-build-init docker-build-python deploy load-images undeploy kind-create kind-delete - -KIND_CLUSTER_NAME ?= kagenti # default to kagenti cluster name - -# Docker build targets -docker-build-proxy: - podman build -t auth-proxy:latest . - -docker-build-target: - cd quickstart/demo-app && podman build -t demo-app:latest -f Dockerfile . - -docker-build-init: - podman build -f Dockerfile.init -t proxy-init:latest . - -# Build demo Docker images (auth-proxy, demo-app, proxy-init). -# The combined sidecar images are built from authbridge/ context: -# cd authbridge && podman build -f cmd/authbridge/Dockerfile.proxy -t authbridge:latest . -# cd authbridge && podman build -f cmd/authbridge/Dockerfile.envoy -t authbridge-envoy:latest . -build-images: docker-build-proxy docker-build-target docker-build-init - -# Load Docker images into kind cluster and re-tag for podman compatibility. -# Podman tags images with a localhost/ prefix, but Kubernetes resolves bare -# image names to docker.io/library/. Re-tagging inside the node ensures the -# images are found when pods are scheduled. -KIND_NODE ?= $(KIND_CLUSTER_NAME)-control-plane -LOCAL_IMAGES = auth-proxy demo-app proxy-init -load-images: - @for img in $(LOCAL_IMAGES); do \ - kind load docker-image $$img:latest --name $(KIND_CLUSTER_NAME); \ - podman exec $(KIND_NODE) ctr --namespace=k8s.io images tag localhost/$$img:latest docker.io/library/$$img:latest 2>/dev/null || true; \ - done - -# Deploy to Kubernetes -deploy: - kubectl apply -f quickstart/k8s/demo-app-deployment.yaml - kubectl apply -f k8s/auth-proxy-deployment.yaml - -# Remove deployment from Kubernetes -undeploy: - kubectl delete -f k8s/auth-proxy-deployment.yaml --ignore-not-found=true - kubectl delete -f quickstart/k8s/demo-app-deployment.yaml --ignore-not-found=true - -# Kind cluster management -kind-create: - kind create cluster --name $(KIND_CLUSTER_NAME) - -kind-delete: - kind delete cluster --name $(KIND_CLUSTER_NAME) - -# Run Go unit tests -unit-test: - go test ./... -v - -# Integration test via curl -test: - @echo "Testing valid authorization..." - @curl -s -H "Authorization: kagenti" http://localhost:8080/test - @echo "" - @echo "Testing invalid authorization..." - @curl -s -H "Authorization: invalid" http://localhost:8080/test - @echo "" - @echo "Testing no authorization..." - @curl -s http://localhost:8080/test - @echo "" diff --git a/authbridge/authproxy/README.md b/authbridge/authproxy/README.md deleted file mode 100644 index 1b6c05d87..000000000 --- a/authbridge/authproxy/README.md +++ /dev/null @@ -1,298 +0,0 @@ -# AuthProxy - -> **Note:** The go-processor ext_proc server has been removed. Auth logic now lives in -> the [unified authbridge binary](../cmd/authbridge/). This directory contains the -> proxy-init iptables setup, combined sidecar image, demo app, and quickstart. - -AuthProxy is a **token validation and exchange sidecar** for Kubernetes workloads. It enables secure service-to-service communication by: -- **Validating** incoming requests with JWT token verification (inbound) -- **Exchanging** tokens for ones with the correct audience for downstream services (outbound) - -## What AuthProxy Does - -AuthProxy solves a common challenge in microservices architectures: **how can a service call another service when each service expects tokens with different audiences?** - -### The Problem - -When a caller obtains a token, it's typically scoped to a specific audience (often the caller itself). If the caller tries to use that token to call a different service, the request will be rejected because the target service expects a different audience. - -``` -┌─────────────┐ ┌──────────────┐ -│ Caller │ ── Token A ────────► │ Target │ ❌ REJECTED -│ (aud: svc-a)│ │ (expects │ Wrong audience! -└─────────────┘ │ aud: target)│ - └──────────────┘ -``` - -### The Solution - -AuthProxy intercepts outgoing requests and exchanges the token for a new one with the correct audience: - -``` -┌─────────────┐ ┌──────────────────────────┐ ┌─────────────┐ -│ Caller │ ── Token A ──►│ AuthProxy │─ Token B ──► │ Target │ ✅ AUTHORIZED -│ │ │ 1. Intercept request │ │ │ -│ Token: │ │ 2. Exchange for new aud │ │ (expects │ -│ (aud: svc-a)│ │ 3. Forward request │ │ aud: target)│ -└─────────────┘ └──────────────────────────┘ └─────────────┘ - │ - ▼ - ┌─────────────────┐ - │ Keycloak │ - │ (Token Exchange)│ - └─────────────────┘ -``` - -## Components - -AuthProxy is a **single sidecar container** that consists of: - -### Envoy Proxy with Ext Proc Filter - -The sidecar runs an Envoy proxy with an external processor (ext-proc) filter: -- **Envoy Proxy** (port **15123** outbound, port **15124** inbound): Intercepts all traffic from and to the application container -- **Ext Proc Filter** ([unified authbridge binary](../cmd/authbridge/), port **9090**): Handles both directions: - - **Inbound**: Validates JWT tokens (signature, issuer) using JWKS. Returns 401 Unauthorized for invalid tokens. - - **Outbound HTTP**: Performs **OAuth 2.0 Token Exchange** ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)), replacing the `Authorization` header with an exchanged token for the target audience. - - **Outbound HTTPS**: Envoy detects TLS via `tls_inspector` and passes traffic through as-is using `tcp_proxy` (no ext_proc, no token exchange). This ensures HTTPS connections are not broken by the sidecar. - - Direction is detected via the `x-authbridge-direction` header injected by Envoy's inbound listener. - -### Traffic Interception via iptables - -To automatically route traffic, an **init container** (`proxy-init`) configures **iptables rules**: -- **Outbound** (OUTPUT chain): Redirects outgoing traffic to the Envoy outbound listener (port 15123) -- **Inbound** (PREROUTING chain): Redirects incoming traffic to the Envoy inbound listener (port 15124) - -This ensures transparent interception in both directions without requiring any changes to the application code. - -### Example Application (`main.go`) - -The `main.go` file in this directory is **not** a core component of AuthProxy. It is an **example pass-through proxy** that forwards requests to a target service. JWT validation is handled entirely by the Ext Proc on the inbound path. Any application can benefit from AuthProxy simply by being deployed alongside the sidecar—no code changes required. - -## Architecture - -### Sidecar Deployment - -When deployed as a sidecar, AuthProxy intercepts both **inbound** and **outbound** traffic via iptables: - -``` - Incoming request - │ - ▼ -┌───────────────────────────────────────────────────────────-───────┐ -│ POD │ -│ │ -│ ┌─────────────┐ ┌──────────────────────────────────────┐ │ -│ │ proxy-init │ │ AuthProxy Sidecar │ │ -│ │ (iptables) │ │ │ │ -│ └──────┬──────┘ │ ┌────────────┐ ┌────────────┐ │ │ -│ │ │ │ Envoy │◄──►│ Ext Proc │ │ │ -│ │ │ │ :15123 │ │ :9090 │ │ │ -│ ▼ │ │ :15124 │ │ │ │ │ -│ ┌─────────────┐ │ └─────┬──────┘ └──────┬─────┘ │ │ -│ │ │◄──────┼────────│ (inbound) │ │ │ -│ │ Application ├───────┼───────►│ (outbound) │ │ │ -│ │ (any app) │ │ │ │ │ │ -│ └─────────────┘ └────────┼──────────────────┼──────────┘ │ -│ │ │ │ -└─────────────────────────────────┼──────────────────┼──────────────┘ - │ │ - ▼ ▼ - ┌──────────────────┐ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐ - │ Target Service │ Keycloak - └──────────────────┘ │(token exchange) │ - ─ ─ ─ ─ ─ ─ ─ ─ ─ -``` - -**How it works:** - -**Inbound (incoming requests):** -1. **proxy-init** (init container): Sets up iptables PREROUTING rules to redirect incoming traffic to Envoy -2. **Envoy** (port 15124): Intercepts incoming traffic, injects `x-authbridge-direction: inbound` header, calls Ext Proc -3. **Ext Proc** (port 9090): Validates JWT token (signature + issuer via JWKS). Returns 401 if invalid. -4. **Envoy**: Forwards validated request to the application - -**Outbound (outgoing requests):** -1. **proxy-init** (init container): Sets up iptables OUTPUT rules to redirect outbound traffic to Envoy -2. **Envoy** (port 15123): Intercepts outbound traffic, uses `tls_inspector` to detect the protocol: - - **HTTP (plaintext)**: Calls Ext Proc via gRPC for token exchange, then forwards with the new Authorization header - - **HTTPS (TLS)**: Passes traffic through as-is via `tcp_proxy` (no token exchange, preserving the original TLS connection) - -The application requires **no code changes**—traffic interception is completely transparent. - -## Configuration - -### Token Exchange Configuration (AuthProxy Sidecar) - -The Ext Proc reads token exchange configuration directly from environment variables at startup: - -| Variable | Description | Source | -|----------|-------------|--------| -| `TOKEN_URL` | Keycloak token endpoint URL | Environment variable | -| `ISSUER` | Expected JWT issuer for inbound validation. Must match Keycloak's frontend URL (the `iss` claim in tokens). Required for inbound JWT validation. | Environment variable | -| `CLIENT_ID` | Client ID for token exchange and inbound audience validation | `/shared/client-id.txt` file or `CLIENT_ID` env var | -| `CLIENT_SECRET` | Client secret | `/shared/client-secret.txt` file or `CLIENT_SECRET` env var | - -> **Note:** `CLIENT_ID` and `CLIENT_SECRET` are preferentially loaded from `/shared/` files (when using dynamic client registration with SPIFFE). If files are not available, environment variables are used as fallback. - -> **Note:** Target audience and scopes for outbound token exchange are configured per-route in the `authproxy-routes` ConfigMap, not as global environment variables. See the [AuthBridge CLAUDE.md](../../authbridge/CLAUDE.md) for the `authproxy-routes` format. - -#### Configuration Secret - -Token exchange is typically configured via a Kubernetes Secret: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: auth-proxy-config -stringData: - TOKEN_URL: "http://keycloak:8080/realms/kagenti/protocol/openid-connect/token" - ISSUER: "http://keycloak.example.com:8080/realms/kagenti" # Required: must match Keycloak's frontend URL (iss claim in tokens) - CLIENT_ID: "authproxy" # Also used for inbound audience validation - CLIENT_SECRET: "" -``` - -## Token Exchange Flow - -The Ext Proc performs OAuth 2.0 Token Exchange as defined in [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693): - -``` -POST /realms/kagenti/protocol/openid-connect/token -Content-Type: application/x-www-form-urlencoded - -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -&client_id= -&client_secret= -&subject_token= -&subject_token_type=urn:ietf:params:oauth:token-type:access_token -&requested_token_type=urn:ietf:params:oauth:token-type:access_token -&audience= -&scope= -``` - -**Response:** - -```json -{ - "access_token": "", - "token_type": "Bearer", - "expires_in": 300 -} -``` - -## Quickstart - -This section provides instructions to run the example application with the AuthProxy sidecar, without the full AuthBridge setup (no SPIFFE, no client-registration). - -### Prerequisites - -- Kubernetes cluster (Kind recommended) -- Keycloak deployed (or use [Kagenti installer](https://github.com/kagenti/kagenti/blob/main/docs/install.md)) -- Docker/Podman for building images - -### Step 1: Build and Deploy - -```bash -cd authbridge/authproxy - -# Build all images -make build-images - -# Load into Kind cluster (set KIND_CLUSTER_NAME if not using default) -make load-images - -# Deploy example app with AuthProxy sidecar -make deploy -``` - -This deploys: -- `auth-proxy` - Example pass-through proxy (port 8080) running alongside the AuthProxy sidecar (Envoy + Ext Proc). JWT validation is handled by the inbound Ext Proc. -- `demo-app` - Sample target application (port 8081) that validates exchanged tokens - -### Step 2: Configure Keycloak - -Port-forward Keycloak (in a separate terminal): - -```bash -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -Run the setup script to create necessary Keycloak clients: - -```bash -cd quickstart - -# Setup Python environment -python -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -# Configure Keycloak -python setup_keycloak.py -``` - -The script creates: -- `application-caller` client - for obtaining tokens (password grant) -- `authproxy` client - for token exchange -- `demoapp` client - target audience for token exchange -- `authproxy-aud` and `demoapp-aud` scopes -- A test user (`test-user` / `password`) - -### Step 3: Create auth-proxy-config Secret and Test - -Create the secret for token exchange credentials, then test the flow. See the [Quickstart Guide](./quickstart/README.md) for detailed instructions covering: -- Creating the `auth-proxy-config` Kubernetes Secret -- Port-forwarding the services -- Testing inbound validation (401 for missing/invalid tokens) -- Testing outbound token exchange (200 for valid tokens) - -### View Logs - -```bash -# Example application logs -kubectl logs deployment/auth-proxy -c auth-proxy - -# Ext proc logs (inbound validation + outbound token exchange) -kubectl logs deployment/auth-proxy -c envoy-proxy - -# Demo app (target service) logs -kubectl logs deployment/demo-app - -# Follow ext proc logs in real-time -kubectl logs -f deployment/auth-proxy -c envoy-proxy -``` - -### Clean Up - -```bash -# Remove deployments -make undeploy - -# Delete Kind cluster (if desired) -make kind-delete -``` - -> **📘 For detailed standalone instructions**, see the [Quickstart Guide](./quickstart/README.md). - ---- - -## Viewing Logs - -When running the example deployment: - -```bash -# Example application logs -kubectl logs -c auth-proxy - -# AuthProxy sidecar logs (shows token exchange) -kubectl logs -c envoy-proxy -``` - -## Related Documentation - -- [AuthBridge](../README.md) - Complete AuthBridge overview with token exchange flow -- [AuthBridge Demos](../demos/README.md) - Demo scenarios and getting started -- [Client Registration](../client-registration/README.md) - Automatic Keycloak client registration with SPIFFE -- [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) -- [Envoy External Processing](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter) diff --git a/authbridge/authproxy/go.mod b/authbridge/authproxy/go.mod deleted file mode 100644 index a34f39c37..000000000 --- a/authbridge/authproxy/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module github.com/kagenti/kagenti-extensions/authbridge/authproxy - -go 1.24.0 - -toolchain go1.24.5 - -require github.com/lestrrat-go/jwx/v2 v2.1.6 - -require ( - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/lestrrat-go/blackmagic v1.0.3 // indirect - github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc v1.0.6 // indirect - github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect - github.com/segmentio/asm v1.2.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.41.0 // indirect -) diff --git a/authbridge/authproxy/go.sum b/authbridge/authproxy/go.sum deleted file mode 100644 index 41bc8956d..000000000 --- a/authbridge/authproxy/go.sum +++ /dev/null @@ -1,36 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= -github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= -github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= -github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= -github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/authproxy/k8s/auth-proxy-deployment.yaml b/authbridge/authproxy/k8s/auth-proxy-deployment.yaml deleted file mode 100644 index b09cf3d61..000000000 --- a/authbridge/authproxy/k8s/auth-proxy-deployment.yaml +++ /dev/null @@ -1,298 +0,0 @@ -# ⚠️ BROKEN AFTER kagenti-extensions#411 — references -# localhost/authbridge-unified:latest which no longer publishes; the -# unified binary was split into mode-specific combined images -# (authbridge / authbridge-envoy / authbridge-lite). Applying this -# YAML today will produce ImagePullBackOff. The standalone authproxy -# quickstart needs migration to the combined sidecar shape; use the -# webhook or weather-agent demos in the meantime. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-proxy - labels: - app: auth-proxy -spec: - replicas: 1 - selector: - matchLabels: - app: auth-proxy - template: - metadata: - labels: - app: auth-proxy - # istio.io/dataplane-mode: none - annotations: - # ambient.istio.io/redirection: disabled - sidecar.istio.io/inject: "false" - spec: - initContainers: - - name: proxy-init - image: localhost/proxy-init:latest - imagePullPolicy: IfNotPresent - securityContext: - privileged: false - capabilities: - add: - - NET_ADMIN - - NET_RAW - drop: - - ALL - runAsNonRoot: false - runAsUser: 0 - env: - - name: PROXY_PORT - value: "15123" - - name: INBOUND_PROXY_PORT - value: "15124" - - name: PROXY_UID - value: "1337" - - name: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - resources: - limits: - cpu: 10m - memory: 10Mi - requests: - cpu: 10m - memory: 10Mi - containers: - - name: auth-proxy - image: localhost/auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: TARGET_SERVICE_URL - value: "http://demo-app-service:8081" - - name: TARGET_SERVICE_HTTPS_URL - value: "https://demo-app-service:8443" - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - - name: envoy-proxy - image: localhost/authbridge-unified:latest - imagePullPolicy: IfNotPresent - securityContext: - runAsUser: 1337 - runAsGroup: 1337 - ports: - - containerPort: 15123 - name: envoy-outbound - - containerPort: 15124 - name: envoy-inbound - - containerPort: 9901 - name: envoy-admin - - containerPort: 9090 - name: ext-proc - env: - - name: TOKEN_URL - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: TOKEN_URL - optional: true - - name: ISSUER - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: ISSUER - # Audience validation uses CLIENT_ID from /shared/client-id.txt automatically - - name: CLIENT_ID - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: CLIENT_ID - optional: true - - name: CLIENT_SECRET - valueFrom: - secretKeyRef: - name: auth-proxy-config - key: CLIENT_SECRET - optional: true - volumeMounts: - - name: envoy-config - mountPath: /etc/envoy - readOnly: true - resources: - limits: - cpu: 200m - memory: 256Mi - requests: - cpu: 50m - memory: 64Mi - volumes: - - name: envoy-config - configMap: - name: envoy-config ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - - name: envoy.filters.listener.tls_inspector - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector - filter_chains: - # TLS passthrough: forward HTTPS traffic as-is via TCP proxy - - filter_chain_match: - transport_protocol: tls - filters: - - name: envoy.filters.network.tcp_proxy - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy - stat_prefix: outbound_tls_passthrough - cluster: original_destination - # Plaintext HTTP: inspect and process via ext_proc - - filter_chain_match: - transport_protocol: raw_buffer - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - - name: inbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15124 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: inbound_http - codec_type: AUTO - route_config: - name: inbound_routes - virtual_hosts: - - name: local_app - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.lua - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua - default_source_code: - inline_string: | - function envoy_on_request(request_handle) - request_handle:headers():add("x-authbridge-direction", "inbound") - end - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: ext_proc_cluster - connect_timeout: 5s - type: STATIC - http2_protocol_options: {} - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: ext_proc_cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 - - - name: original_destination - connect_timeout: 30s - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED ---- -apiVersion: v1 -kind: Service -metadata: - name: auth-proxy-service - labels: - app: auth-proxy -spec: - type: ClusterIP - ports: - - port: 8080 - targetPort: 8080 - protocol: TCP - name: http - selector: - app: auth-proxy diff --git a/authbridge/authproxy/main.go b/authbridge/authproxy/main.go deleted file mode 100644 index 099d16a0b..000000000 --- a/authbridge/authproxy/main.go +++ /dev/null @@ -1,113 +0,0 @@ -package main - -import ( - "bytes" - "crypto/tls" - "io" - "log" - "net/http" - "net/url" - "os" - "strings" -) - -const ( - defaultTargetServiceURL = "http://demo-app-service:8081" - defaultTargetServiceHTTPSURL = "https://demo-app-service:8443" - proxyPort = "0.0.0.0:8080" - tlsTestPrefix = "/tls-test" -) - -func main() { - targetServiceURL := os.Getenv("TARGET_SERVICE_URL") - if targetServiceURL == "" { - targetServiceURL = defaultTargetServiceURL - } - - targetServiceHTTPSURL := os.Getenv("TARGET_SERVICE_HTTPS_URL") - if targetServiceHTTPSURL == "" { - targetServiceHTTPSURL = defaultTargetServiceHTTPSURL - } - - // Client for HTTPS target (self-signed cert) - httpsClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if rest, ok := strings.CutPrefix(r.URL.Path, tlsTestPrefix); ok { - // Forward to the HTTPS target with the prefix stripped - r.URL.Path = rest - if r.URL.Path == "" { - r.URL.Path = "/" - } - proxyHandlerWithClient(w, r, targetServiceHTTPSURL, httpsClient) - } else { - proxyHandler(w, r, targetServiceURL) - } - }) - log.Printf("Auth proxy starting on port %s", proxyPort) - log.Printf("Forwarding HTTP requests to %s", targetServiceURL) - log.Printf("Forwarding HTTPS requests (/tls-test) to %s", targetServiceHTTPSURL) - log.Printf("JWT validation is handled by the inbound ext proc") - log.Fatal(http.ListenAndServe(proxyPort, nil)) -} - -var defaultClient = &http.Client{} - -func proxyHandler(w http.ResponseWriter, r *http.Request, targetServiceURL string) { - proxyHandlerWithClient(w, r, targetServiceURL, defaultClient) -} - -func proxyHandlerWithClient(w http.ResponseWriter, r *http.Request, targetServiceURL string, client *http.Client) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusInternalServerError) - return - } - defer r.Body.Close() - - targetURL, err := url.Parse(targetServiceURL + r.URL.Path) - if err != nil { - http.Error(w, "Invalid target URL", http.StatusInternalServerError) - return - } - - proxyReq, err := http.NewRequest(r.Method, targetURL.String(), bytes.NewReader(body)) - if err != nil { - http.Error(w, "Failed to create proxy request", http.StatusInternalServerError) - return - } - - for key, values := range r.Header { - for _, value := range values { - proxyReq.Header.Add(key, value) - } - } - - resp, err := client.Do(proxyReq) - if err != nil { - http.Error(w, "Failed to forward request", http.StatusBadGateway) - return - } - defer resp.Body.Close() - - for key, values := range resp.Header { - for _, value := range values { - w.Header().Add(key, value) - } - } - w.WriteHeader(resp.StatusCode) - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - http.Error(w, "Failed to read response", http.StatusInternalServerError) - return - } - - w.Write(respBody) - - log.Printf("Forwarded %s %s -> %s - Status: %d", r.Method, r.URL.Path, targetServiceURL, resp.StatusCode) -} diff --git a/authbridge/authproxy/quickstart/README.md b/authbridge/authproxy/quickstart/README.md deleted file mode 100644 index 24d808c8c..000000000 --- a/authbridge/authproxy/quickstart/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# AuthProxy quickstart - -This document gives a step-by-step tutorial of getting started with the AuthProxy in a local Kind cluster. - -The final architecture deployed is as follows: - -``` -Caller ──► AuthProxy Pod ──► Demo App - (inbound: JWT processing) - (outbound HTTP: token exchange via ext_proc) - (outbound HTTPS: TLS passthrough via tcp_proxy) -``` - -The AuthProxy pod intercepts traffic in both directions: -- **Inbound**: Processes JWT tokens on incoming requests (validates if present, returns 401 if invalid) -- **Outbound HTTP**: Exchanges tokens for the correct audience before forwarding to the Demo App -- **Outbound HTTPS**: Envoy detects TLS and passes traffic through as-is (no ext_proc, no token exchange) - -The demo goes as follows: -1. Install Kagenti -1. Build and deploy the Demo App and AuthProxy -1. Configure Keycloak -1. Create the auth-proxy-config secret -1. Test the flow - -## Step 1: Install Kagenti -First, we recommend to deploy Kagenti to a local Kind cluster with the Ansible installer as service urls used below are derived from that installation. Instructions are available [here](https://github.com/kagenti/kagenti/blob/main/docs/install.md#ansible-based-installer-recommended). - -This should start a local Kind cluster named `kagenti`. - -The key component is Keycloak which has been deployed to the `keycloak` namespace and exposed as `keycloak-service`. - -## Step 2: Build and deploy the Demo App and AuthProxy - -Let's clone the assets locally: - -```bash -git clone git@github.com:kagenti/kagenti-extensions.git -cd kagenti-extensions/authbridge/authproxy -``` - -We can use the following `make` commands to build and load the images to the Kind cluster: - -```bash -make build-images -make load-images -``` - -If the above gives error `ERROR: no nodes found...` set the `KIND_CLUSTER_NAME` environment variable to the name of the kind cluster you are using. - -Then we can create two deployments in Kubernetes: - -```bash -make deploy -``` - -## Step 3: Configure Keycloak - -Port-forward Keycloak to access it locally (in a separate terminal): - -```bash -kubectl port-forward service/keycloak-service -n keycloak 8080:8080 -``` - -Now set up a Python environment and run the setup script: - -```bash -cd quickstart -python -m venv venv -source venv/bin/activate -pip install --upgrade pip -pip install -r requirements.txt -python setup_keycloak.py -``` - -The script creates: -- `application-caller` client - for obtaining initial tokens (password grant) -- `authproxy` client - used by the AuthProxy sidecar for token exchange -- `demoapp` client - target audience for token exchange -- `authproxy-aud` scope - adds `authproxy` to token audience -- `demoapp-aud` scope - adds `demoapp` to exchanged token audience -- `test-user` / `password` - demo user for testing - -## Step 4: Create the auth-proxy-config Secret - -The AuthProxy sidecar needs credentials for token exchange. Get the `authproxy` client secret and create the Kubernetes secret: - -```bash -# Get admin token -ADMIN_TOKEN=$(curl -s -X POST "http://keycloak.localtest.me:8080/realms/master/protocol/openid-connect/token" \ - -d "client_id=admin-cli" -d "grant_type=password" -d "username=admin" -d "password=admin" | jq -r '.access_token') - -# Get authproxy client secret -AUTHPROXY_SECRET=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients?clientId=authproxy" | jq -r '.[0].secret') - -# Create the secret -kubectl create secret generic auth-proxy-config \ - --from-literal=TOKEN_URL="http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/token" \ - --from-literal=ISSUER="http://keycloak.localtest.me:8080/realms/kagenti" \ - --from-literal=CLIENT_ID="authproxy" \ - --from-literal=CLIENT_SECRET="$AUTHPROXY_SECRET" -``` - -Then restart the auth-proxy deployment to pick up the secret: - -```bash -kubectl rollout restart deployment auth-proxy -kubectl rollout status deployment auth-proxy --timeout=120s -``` - -## Step 5: Test the Flow - -Port-forward the AuthProxy service. Use port 9080 since Keycloak is already using 8080: - -```bash -kubectl port-forward svc/auth-proxy-service 9080:8080 -``` - -Wait for the ext proc to initialize (it takes up to 60 seconds to load credentials on first startup), then get a token and test: - -```bash -# Get application-caller client secret -ADMIN_TOKEN=$(curl -s -X POST "http://keycloak.localtest.me:8080/realms/master/protocol/openid-connect/token" \ - -d "client_id=admin-cli" -d "grant_type=password" -d "username=admin" -d "password=admin" | jq -r '.access_token') - -APP_SECRET=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients?clientId=application-caller" | jq -r '.[0].secret') - -# Get an access token (password grant with test-user) -export ACCESS_TOKEN=$(curl -s -X POST \ - "http://keycloak.localtest.me:8080/realms/kagenti/protocol/openid-connect/token" \ - -d "grant_type=password" \ - -d "client_id=application-caller" \ - -d "client_secret=$APP_SECRET" \ - -d "username=test-user" \ - -d "password=password" \ - -d "scope=openid authproxy-aud" | jq -r '.access_token') -``` - -### HTTP test (token exchange via ext_proc) - -**Valid request (inbound validation passes, token exchange, forwarded to demo-app):** -```bash -curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:9080/test -# Expected response: "authorized" -``` - -This exercises the full outbound HTTP path: Envoy intercepts the outbound request via `http_connection_manager`, the ext_proc exchanges the token for the `demoapp` audience, and demo-app validates the JWT. - -**Invalid token (rejected by demo-app JWT validation):** -```bash -curl -H "Authorization: Bearer invalid-token" http://localhost:9080/test -# Expected response: "unauthorized" -``` - -**No authorization header (rejected by demo-app JWT validation):** -```bash -curl http://localhost:9080/test -# Expected response: "unauthorized: missing Authorization header" -``` - -### AgentCard discovery (A2A) - -**Public endpoint — no authentication required:** -```bash -curl http://localhost:9080/.well-known/agent.json -# Expected: JSON AgentCard with agent name, skills, protocol version -``` - -The `/.well-known/agent.json` endpoint is a public [A2A](https://google.github.io/A2A/) discovery endpoint that does not require JWT authentication. This is the use case motivating route-based auth bypass (see [issue #124](https://github.com/kagenti/kagenti-extensions/issues/124)) — public discovery endpoints like AgentCard must be accessible without a token, even when the rest of the application is protected by AuthBridge. - -### HTTPS test (TLS passthrough) - -**HTTPS connectivity through Envoy TLS passthrough:** -```bash -curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:9080/tls-test -# Expected response: "tls-ok" -``` - -This exercises the outbound HTTPS path: the auth-proxy makes an HTTPS request to `demo-app-service:8443`, Envoy detects TLS via `tls_inspector` and forwards it as-is through `tcp_proxy` (no ext_proc, no token exchange). The demo-app HTTPS port serves a simple echo response without JWT validation — it only proves HTTPS connectivity through TLS passthrough works. - -**No authorization header (passes through — no JWT validation on this path):** -```bash -curl http://localhost:9080/tls-test -# Expected response: "tls-ok" -``` - -Note: The HTTPS path has no JWT validation at either end. The inbound ext_proc processes tokens when present but does not enforce that a token must exist. The outbound HTTPS path uses TLS passthrough (no ext_proc), and the demo-app HTTPS port has no JWT validation. Authentication on the HTTP path (`/test`) is enforced by the demo-app itself, which validates the exchanged token. - -## Kubernetes Testing - -When deployed to Kubernetes, you can test the services internally: - -**Test demo app directly:** -```bash -kubectl run test-pod --image=curlimages/curl --rm -it --restart=Never -- curl -H "Authorization: Bearer $ACCESS_TOKEN" http://demo-app-service:8081/test -``` - -**View logs:** -```bash -# Auth proxy logs (pass-through proxy) -kubectl logs deployment/auth-proxy -c auth-proxy - -# Envoy proxy + ext proc logs (inbound validation and outbound token exchange) -kubectl logs deployment/auth-proxy -c envoy-proxy - -# Demo app logs -kubectl logs deployment/demo-app - -# Follow logs in real-time -kubectl logs -f deployment/auth-proxy -c envoy-proxy -``` - -**Check service status:** -```bash -# List pods -kubectl get pods - -# List services -kubectl get svc - -# Describe deployments -kubectl describe deployment auth-proxy -kubectl describe deployment demo-app -``` - -## Clean Up - -**Remove Kubernetes deployment:** -```bash -make undeploy -kubectl delete secret auth-proxy-config --ignore-not-found=true -``` - -**Delete kind cluster:** -```bash -make kind-delete -``` diff --git a/authbridge/authproxy/quickstart/demo-app/Dockerfile b/authbridge/authproxy/quickstart/demo-app/Dockerfile deleted file mode 100644 index 612bf5a79..000000000 --- a/authbridge/authproxy/quickstart/demo-app/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM golang:1.26-alpine AS builder - -WORKDIR /app - -# Initialize go module for demo app -RUN go mod init demo-app - -# Copy source code first to analyze dependencies -COPY main.go . - -# Add required dependencies -RUN go get github.com/lestrrat-go/jwx/v2/jwk github.com/lestrrat-go/jwx/v2/jwt - -# Download dependencies (go.sum will be created automatically) -RUN go mod download - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o target . - -# Final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates - -WORKDIR /root/ - -# Copy the binary from builder stage -COPY --from=builder /app/target . - -EXPOSE 8081 8443 - -CMD ["./target"] diff --git a/authbridge/authproxy/quickstart/demo-app/main.go b/authbridge/authproxy/quickstart/demo-app/main.go deleted file mode 100644 index cf6256af6..000000000 --- a/authbridge/authproxy/quickstart/demo-app/main.go +++ /dev/null @@ -1,252 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "fmt" - "log" - "math/big" - "net/http" - "os" - "strings" - "time" - - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" -) - -const ( - httpPort = "0.0.0.0:8081" - httpsPort = "0.0.0.0:8443" -) - -var jwksCache *jwk.Cache - -func main() { - jwksURL := os.Getenv("JWKS_URL") - if jwksURL == "" { - log.Fatal("JWKS_URL environment variable is required") - } - - issuer := os.Getenv("ISSUER") - if issuer == "" { - log.Fatal("ISSUER environment variable is required") - } - - audience := os.Getenv("AUDIENCE") - if audience == "" { - log.Fatal("AUDIENCE environment variable is required") - } - - // Initialize JWKS cache - ctx := context.Background() - jwksCache = jwk.NewCache(ctx) - if err := jwksCache.Register(jwksURL); err != nil { - log.Fatalf("Failed to register JWKS URL: %v", err) - } - - // HTTP server on port 8081 with JWT validation - httpMux := http.NewServeMux() - httpMux.HandleFunc("/.well-known/agent.json", agentCardHandler) - httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - authHandler(w, r, jwksURL, issuer, audience) - }) - - // HTTPS server on port 8443 — simple echo, no JWT validation. - // This port is used to verify TLS passthrough through Envoy works. - httpsMux := http.NewServeMux() - httpsMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("tls-ok")) - log.Printf("HTTPS request served: %s %s", r.Method, r.URL.Path) - }) - - tlsCert, err := generateSelfSignedCert() - if err != nil { - log.Fatalf("Failed to generate self-signed TLS certificate: %v", err) - } - - httpsServer := &http.Server{ - Addr: httpsPort, - Handler: httpsMux, - TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, - }, - } - - log.Printf("Demo app HTTP starting on %s (JWT validation enabled)", httpPort) - log.Printf("Demo app HTTPS starting on %s (echo only, no JWT validation)", httpsPort) - log.Printf("JWKS URL: %s", jwksURL) - log.Printf("Expected issuer: %s", issuer) - log.Printf("Expected audience: %s", audience) - - // Start HTTPS listener in a goroutine - go func() { - // TLSConfig already has the cert; pass empty strings to use it - if err := httpsServer.ListenAndServeTLS("", ""); err != nil { - log.Fatalf("HTTPS server failed: %v", err) - } - }() - - log.Fatal(http.ListenAndServe(httpPort, httpMux)) -} - -// generateSelfSignedCert creates an in-memory self-signed TLS certificate. -func generateSelfSignedCert() (tls.Certificate, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate key: %w", err) - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, fmt.Errorf("generate serial: %w", err) - } - - template := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: "demo-app"}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{"demo-app-service", "localhost"}, - } - - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) - if err != nil { - return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) - } - - return tls.Certificate{ - Certificate: [][]byte{certDER}, - PrivateKey: key, - }, nil -} - -func agentCardHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - card := map[string]interface{}{ - "name": "demo-agent", - "description": "A simple A2A demo agent for AuthBridge quickstart", - "version": "1.0.0", - "url": "http://localhost:8081", - "protocolVersion": "0.2.6", - "capabilities": map[string]interface{}{}, - "defaultInputModes": []string{"text/plain"}, - "defaultOutputModes": []string{"text/plain"}, - "skills": []map[string]interface{}{ - { - "id": "echo", - "name": "Echo", - "description": "Echoes back the input message", - "tags": []string{"utility"}, - "inputModes": []string{"text/plain"}, - "outputModes": []string{"text/plain"}, - }, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(card) - log.Printf("AgentCard served: %s %s", r.Method, r.URL.Path) -} - -func validateJWT(tokenString, jwksURL, expectedIssuer, expectedAudience string) error { - ctx := context.Background() - - // Fetch JWKS from cache - keySet, err := jwksCache.Get(ctx, jwksURL) - if err != nil { - return fmt.Errorf("failed to fetch JWKS: %w", err) - } - - // Parse and validate the token - token, err := jwt.Parse([]byte(tokenString), jwt.WithKeySet(keySet), jwt.WithValidate(true)) - if err != nil { - return fmt.Errorf("failed to parse/validate token: %w", err) - } - - // Validate issuer claim - if token.Issuer() != expectedIssuer { - return fmt.Errorf("invalid issuer: expected %s, got %s", expectedIssuer, token.Issuer()) - } - - // Validate audience claim - audiences := token.Audience() - validAudience := false - for _, aud := range audiences { - if aud == expectedAudience { - validAudience = true - break - } - } - if !validAudience { - return fmt.Errorf("invalid audience: expected %s, got %v", expectedAudience, audiences) - } - - // Log JWT claims for debugging - log.Printf("[JWT Debug] Successfully validated token") - log.Printf("[JWT Debug] Issuer: %s", token.Issuer()) - log.Printf("[JWT Debug] Subject: %s", token.Subject()) - log.Printf("[JWT Debug] Audience: %v", audiences) - - // Extract and log preferred_username if present (shows the actual username) - if preferredUsername, ok := token.Get("preferred_username"); ok { - log.Printf("[JWT Debug] Preferred Username: %v", preferredUsername) - } - - // Extract and log azp (authorized party) if present - if azp, ok := token.Get("azp"); ok { - log.Printf("[JWT Debug] Authorized Party (azp): %v", azp) - } - - // Extract and log scope claim if present - if scopeClaim, ok := token.Get("scope"); ok { - log.Printf("[JWT Debug] Scope: %v", scopeClaim) - } else { - log.Printf("[JWT Debug] Scope: ") - } - - return nil -} - -func authHandler(w http.ResponseWriter, r *http.Request, jwksURL, issuer, audience string) { - authHeader := r.Header.Get("Authorization") - - if authHeader == "" { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("unauthorized: missing Authorization header")) - log.Printf("Unauthorized request (missing auth header): %s %s", r.Method, r.URL.Path) - return - } - - // Extract token from "Bearer " format - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("unauthorized: invalid Authorization header format")) - log.Printf("Unauthorized request (invalid auth format): %s %s", r.Method, r.URL.Path) - return - } - - // Validate JWT - if err := validateJWT(tokenString, jwksURL, issuer, audience); err != nil { - w.WriteHeader(http.StatusUnauthorized) - w.Write([]byte("unauthorized")) - log.Printf("Unauthorized request (invalid token): %s %s - %v", r.Method, r.URL.Path, err) - return - } - - w.WriteHeader(http.StatusOK) - w.Write([]byte("authorized")) - log.Printf("Authorized request: %s %s", r.Method, r.URL.Path) -} diff --git a/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml b/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml deleted file mode 100644 index a20b4ea64..000000000 --- a/authbridge/authproxy/quickstart/k8s/demo-app-deployment.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: demo-app - labels: - app: demo-app -spec: - replicas: 1 - selector: - matchLabels: - app: demo-app - template: - metadata: - labels: - app: demo-app - spec: - containers: - - name: demo-app - image: localhost/demo-app:latest - imagePullPolicy: Never - ports: - - containerPort: 8081 - - containerPort: 8443 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "demoapp" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" ---- -apiVersion: v1 -kind: Service -metadata: - name: demo-app-service -spec: - selector: - app: demo-app - ports: - - protocol: TCP - port: 8081 - targetPort: 8081 - name: http - - protocol: TCP - port: 8443 - targetPort: 8443 - name: https - type: ClusterIP diff --git a/authbridge/authproxy/quickstart/requirements.txt b/authbridge/authproxy/quickstart/requirements.txt deleted file mode 100644 index 3a8919c38..000000000 --- a/authbridge/authproxy/quickstart/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -python-keycloak==5.3.1 diff --git a/authbridge/authproxy/quickstart/setup_keycloak.py b/authbridge/authproxy/quickstart/setup_keycloak.py deleted file mode 100644 index 1d2f900d8..000000000 --- a/authbridge/authproxy/quickstart/setup_keycloak.py +++ /dev/null @@ -1,194 +0,0 @@ -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) - if existing_user: - 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, - ) - 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.") - return existing_client_id - client_id = keycloak_admin.create_client(client_payload) - 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: - print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope["id"] - - # Create new scope - try: - scope_id = keycloak_admin.create_client_scope(scope_payload) - print(f"Created client scope '{scope_name}': {scope_id}") - return scope_id - except KeycloakPostError as e: - 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. - """ - # Note: we do not pre-check for existing mappers here; Keycloak will handle duplicates or raise errors. - - mapper_payload = { - "name": mapper_name, - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": False, - "config": { - "included.custom.audience": audience, - "id.token.claim": "false", - "access.token.claim": "true", - "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( - server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USERNAME, - password=KEYCLOAK_ADMIN_PASSWORD, - realm_name=KEYCLOAK_REALM, - user_realm_name="master", -) - -# create test-user -test_user_name = "test-user" -user_id = get_or_create_user(keycloak_admin, test_user_name) -keycloak_admin.set_user_password(user_id, "password", temporary=False) -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, - }, -) - -# 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"}, - }, -) - -# 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, - }, -) - -# 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"}, - }, -) -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"}, - }, -) -add_audience_mapper(keycloak_admin, demoapp_scope_id, "demoapp-aud", "demoapp") - -# Assign default scopes -try: - keycloak_admin.add_client_default_client_scope(app_caller_id, authproxy_scope_id, {}) - print("Assigned 'authproxy-aud' as default scope to 'application-caller'.") -except Exception as e: - # Keycloak might raise error if already assigned - print(f"Note: Could not assign 'authproxy-aud' scope (might already exist): {e}") - -# Add 'demoapp-aud' to 'authproxy' as default -try: - keycloak_admin.add_client_default_client_scope(authproxy_id, demoapp_scope_id, {}) - print("Assigned 'demoapp-aud' as default scope to 'authproxy'.") -except Exception as e: - print(f"Note: Could not assign 'demoapp-aud' scope (might already exist): {e}") - -print("-" * 50) -try: - 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) diff --git a/authbridge/demos/README.md b/authbridge/demos/README.md index d3eb49428..c87513e1e 100644 --- a/authbridge/demos/README.md +++ b/authbridge/demos/README.md @@ -18,7 +18,6 @@ more AuthBridge capabilities. | **[Weather Agent](weather-agent/demo-ui.md)** | Beginner | Inbound JWT validation, automatic identity registration, outbound passthrough | UI | | **[Weather Agent (advanced)](weather-agent/demo-ui-advanced.md)** | Intermediate | Inbound on agent **and** tool, outbound token exchange, ingress JWT verification on the tool | [kubectl + script](weather-agent/demo-ui-advanced.md#automated-deploy-and-verify-ci-oriented) | | **[GitHub Issue Agent](github-issue/demo.md)** | Intermediate | Inbound validation + outbound token exchange + scope-based access control | [UI](github-issue/demo-ui.md) or [Manual](github-issue/demo-manual.md) | -| **[Webhook](webhook/README.md)** | Intermediate | Webhook-based sidecar injection with auth-target demo app | Manual | | **[Token-Exchange Routes](token-exchange-routes/README.md)** | Reference | How to write `authproxy-routes` for single- and multi-target token exchange | Configuration only | | **[MCP Parser Plugin](mcp-parser/README.md)** | Reference | Enable the `mcp-parser` plugin to surface tool calls / resource reads in session events | Configuration only | | **[abctl Walkthrough](weather-agent/demo-with-abctl.md)** | Reference | Watch the AuthBridge plugin pipeline live with the `abctl` TUI | Tooling only | @@ -70,12 +69,6 @@ more AuthBridge capabilities. - Scope-based access control: Alice (public repos) vs Bob (all repos) - Comprehensive CLI testing and AuthProxy log verification -### Webhook Demo -- Demonstrates the [kagenti-operator](https://github.com/kagenti/kagenti-operator) sidecar injection mechanism -- Deploys a generic agent + auth-target (not a real-world agent) -- Tests inbound validation and outbound token exchange end-to-end -- Good for understanding the injection labels and ConfigMap requirements - ### Token-Exchange Routes (Configuration Reference) - How AuthBridge resolves the request `Host` header to a route entry - ConfigMap shape for `authproxy-routes` — fields, glob patterns, ordering diff --git a/authbridge/demos/token-exchange-routes/README.md b/authbridge/demos/token-exchange-routes/README.md index 3eaf59640..61b34842e 100644 --- a/authbridge/demos/token-exchange-routes/README.md +++ b/authbridge/demos/token-exchange-routes/README.md @@ -11,8 +11,8 @@ Pair it with one of the deployment demos for a working stack: - [`weather-agent/demo-ui-advanced.md`](../weather-agent/demo-ui-advanced.md) — agent + tool with token exchange, runs end-to-end. -- [`webhook/README.md`](../webhook/README.md) — manual webhook - injection with the auth-target demo app. +- [`github-issue/demo.md`](../github-issue/demo.md) — agent + tool + with token exchange and scope-based access control. ## How outbound routing works @@ -201,5 +201,6 @@ rejected the call. — full `token-exchange` plugin reference. - [`weather-agent/demo-ui-advanced.md`](../weather-agent/demo-ui-advanced.md) — end-to-end demo that exercises the single-target route pattern. -- [`webhook/README.md`](../webhook/README.md) — webhook injection - walkthrough that the routes here plug into. +- [`github-issue/demo.md`](../github-issue/demo.md) — multi-feature + demo (token exchange + scope-based access) that the routes here + plug into. diff --git a/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml b/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml index 83633feb4..9e5c692c8 100644 --- a/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml +++ b/authbridge/demos/weather-agent/k8s/weather-tool-advanced.yaml @@ -6,8 +6,9 @@ # The MCP server listens on port 8000. AuthBridge ext_proc listens on 9090 # inside the pod, so there is no port collision (unlike the GitHub tool on 9090). # -# The kagenti AuthBridge mutating webhook only injects sidecars for pod labels -# kagenti.io/type: agent (see AuthBridge/demos/webhook/README.md). This workload is +# The kagenti AuthBridge mutating webhook only injects sidecars for pod +# labels kagenti.io/type: agent (see kagenti-operator/internal/webhook/ +# in the kagenti-operator repo for the matching rule). This workload is # still the weather MCP tool by image and name; the pod is labeled as agent so # client-registration runs and registers the tool SPIFFE in Keycloak. # diff --git a/authbridge/demos/webhook/README.md b/authbridge/demos/webhook/README.md deleted file mode 100644 index 9f2e4f432..000000000 --- a/authbridge/demos/webhook/README.md +++ /dev/null @@ -1,450 +0,0 @@ -# AuthBridge Webhook Demo - -This guide demonstrates how the **kagenti-operator** webhook automatically injects AuthBridge sidecars into your deployments for transparent OAuth 2.0 token exchange. - -> **Note:** The webhook is deployed via [kagenti/kagenti-operator](https://github.com/kagenti/kagenti-operator). See the operator docs for installation. - -## Overview - -The operator webhook watches for deployments with the `kagenti.io/inject: enabled` label and automatically injects an AuthBridge sidecar. After kagenti-extensions#411 there is a single combined sidecar per mode (no more separate `envoy-proxy` + `spiffe-helper` + `client-registration` containers); the legacy multi-sidecar shape and the `combinedSidecar` feature gate are gone. The operator picks the mode per workload via `AgentRuntime.Spec.AuthBridgeMode` → namespace ConfigMap → deprecated annotation → cluster default (`proxy-sidecar`). - -### proxy-sidecar mode (default) - -| Container | Purpose | -|-----------|---------| -| `authbridge-proxy` | Combined sidecar from the `authbridge` image. Runs the HTTP forward + reverse proxies with bundled `spiffe-helper`, gated per workload by `SPIRE_ENABLED`. | - -### envoy-sidecar mode - -| Container | Purpose | -|-----------|---------| -| `proxy-init` | Init container that sets up iptables to redirect inbound and outbound traffic | -| `envoy-proxy` | Combined sidecar from the `authbridge-envoy` image. Runs Envoy + ext_proc + bundled `spiffe-helper`. | - -In both modes, Keycloak client registration is handled by the kagenti-operator's `ClientRegistrationReconciler` and the resulting `kagenti-keycloak-client-credentials-` Secret is mounted at `/shared/` by the webhook. - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Agent Pod │ -│ ┌─────────────┐ ┌──────────────────────────────────────┐ │ -│ │ agent │ ───────▶│ AuthBridge sidecar │ │ -│ │ (your app) │ HTTP │ container name = mode-dependent: │ │ -│ └─────────────┘ with │ proxy-sidecar: authbridge-proxy │ │ -│ token │ envoy-sidecar: envoy-proxy │ │ -│ │ │ │ -│ │ Inbound: validates JWT, returns │ │ -│ │ 401 on invalid/missing │ │ -│ │ Outbound: routes via authproxy-routes;│ │ -│ │ HTTP → token-exchange via Keycloak;│ │ -│ │ HTTPS → TLS passthrough │ │ -│ │ │ │ -│ │ spiffe-helper bundled inside │ │ -│ │ (gated by SPIRE_ENABLED) │ │ -│ └──────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - │ HTTP Request with Exchanged Token - ▼ - ┌─────────────────┐ - │ auth-target │ - │ (validates aud: │ - │ auth-target) │ - └─────────────────┘ - -# Out-of-band: kagenti-operator's ClientRegistrationReconciler creates -# the Keycloak client + a kagenti-keycloak-client-credentials- -# Secret. The webhook mounts that Secret into the AuthBridge sidecar at -# /shared/client-{id,secret}.txt — no in-pod registration sidecar. -``` - -## Prerequisites - -1. **Kubernetes cluster** with the [kagenti-operator](https://github.com/kagenti/kagenti-operator) installed -2. **Keycloak** deployed in the `keycloak` namespace -3. **SPIRE** deployed (optional, for SPIFFE-based identity) -4. **AuthBridge images** available from GitHub Container Registry: - - `ghcr.io/kagenti/kagenti-extensions/authbridge:latest` (proxy-sidecar combined image) - - `ghcr.io/kagenti/kagenti-extensions/authbridge-envoy:latest` (envoy-sidecar combined image) - - `ghcr.io/kagenti/kagenti-extensions/proxy-init:latest` (envoy-sidecar mode only) - ---- - -## Deploy Webhook - -Deploy the operator webhook following the [kagenti-operator installation docs](https://github.com/kagenti/kagenti-operator). - -Then create the namespace and apply the required ConfigMaps: - -```bash -kubectl create namespace team1 -kubectl apply -f k8s/configmaps-webhook.yaml -n team1 -``` - -The ConfigMaps provide: - -**Note for custom deployments:** `TOKEN_URL` and `ISSUER` are auto-derived from `KEYCLOAK_URL` + `KEYCLOAK_REALM`. Set `ISSUER` explicitly only when the internal `KEYCLOAK_URL` differs from the frontend URL that appears in token `iss` claims (split-horizon DNS). Audience validation is automatic using the agent's CLIENT_ID from `/shared/client-id.txt`. - -The ConfigMaps include: - -- `authbridge-config` - Unified Keycloak configuration for both client-registration and envoy-proxy: - - `KEYCLOAK_URL` - Keycloak server URL (used by client-registration and to derive TOKEN_URL/ISSUER) - - `KEYCLOAK_REALM` - Keycloak realm name - - `TOKEN_URL` - Keycloak token endpoint (optional, auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM) - - `ISSUER` - Expected JWT issuer for inbound validation (optional, auto-derived or set explicitly for split-horizon DNS) - - Audience validation uses CLIENT_ID from `/shared/client-id.txt` automatically (no configuration needed) - - Target audience and scopes for outbound token exchange are configured per-route in the `authproxy-routes` ConfigMap -- `spiffe-helper-config` - SPIFFE helper configuration (for SPIRE mode) -- `envoy-config` - Envoy proxy configuration - -## Labels Reference - -| Label | Value | Description | -|-------|-------|-------------| -| `kagenti.io/type` | `agent` | **Required**: Identifies workload as an agent | -| `kagenti.io/inject` | `enabled` | Enable AuthBridge sidecar injection | -| `kagenti.io/inject` | `disabled` | Disable injection (for target services) | -| `kagenti.io/spire` | `enabled` | Enable SPIFFE-based identity with SPIRE | -| `kagenti.io/spire` | `disabled` | Use static client ID (no SPIRE) | - -**Note**: All labels must be on the **Pod template** (`spec.template.metadata.labels`), not the Deployment metadata. - -## Mode selection - -After kagenti-extensions#411 / kagenti-operator#361 the operator resolves AuthBridge mode per workload from this chain: - -1. `AgentRuntime.Spec.AuthBridgeMode` on the workload's CR (canonical surface). -2. `mode:` field on the namespace-level `authbridge-runtime-config` ConfigMap. -3. The deprecated `kagenti.io/authbridge-mode` pod annotation (still honored). -4. Cluster-wide default — `proxy-sidecar`. - -**All four feature gates (`combinedSidecar`, `envoyProxy`, `spiffeHelper`, `clientRegistration`) are removed.** Their behavior was the legacy multi-sidecar shape that no longer exists. The legacy `kagenti.io/client-registration-inject: "true"` label is also **no longer functional** — setting it today silently disables registration (the operator's `SkipReason` still honors it and steps aside, but the in-pod sidecar that was supposed to take over is gone). Do not add it to new manifests. - -To pick a mode for a specific workload, set it on the AgentRuntime CR: - -```yaml -apiVersion: kagenti.io/v1alpha1 -kind: AgentRuntime -metadata: - name: my-agent -spec: - authBridgeMode: envoy-sidecar # or proxy-sidecar / lite / waypoint - targetRef: - apiVersion: apps/v1 - kind: Deployment - name: my-agent -``` - -Then continue with: -- [Step 1: Setup Keycloak](#step-1-setup-keycloak) - Configure Keycloak clients and scopes -- [Step 2: Deploy Auth Target and Agent](#step-2-deploy-auth-target-and-agent) - Deploy the demo workloads -- [Step 3: Test Token Exchange](#step-3-test-the-flow) - Verify the flow works - ---- - -## Demo Deployment Steps - -### Step 1: Setup Keycloak - -Run the Keycloak setup script to configure the realm, clients, and scopes: - -```bash -cd authbridge - -# Activate virtual environment -python -m venv venv -source venv/bin/activate - -pip install --upgrade pip -pip install -r requirements.txt - -cd demos/webhook -# Run setup for webhook deployment (default: team1 namespace, agent service account) -python setup_keycloak.py -``` - -Or specify custom namespace/service account: - -```bash -python setup_keycloak.py --namespace myapp --service-account mysa -``` - -This creates: - -- `auth-target` client (target audience for token exchange) -- `agent---aud` scope (adds agent's SPIFFE ID to token audience) -- `auth-target-aud` scope (adds "auth-target" to exchanged tokens) -- `alice` demo user (for testing subject preservation) - -### Step 2: Deploy Auth Target and Agent - -Deploy the target service and agent workload: - -```bash -# Deploy auth-target (validates exchanged tokens) -# Note: auth-target has kagenti.io/inject: disabled to prevent sidecar injection -kubectl apply -f k8s/auth-target-deployment-webhook.yaml - -# Deploy agent - choose ONE of the following: - -# Option A: With SPIFFE (requires SPIRE) -kubectl apply -f k8s/agent-deployment-webhook.yaml - -# Option B: Without SPIFFE (uses static client ID) -kubectl apply -f k8s/agent-deployment-webhook-no-spiffe.yaml - -# Wait for the pods to be ready: -kubectl wait --for=condition=available --timeout=180s deployment/auth-target -n team1 -kubectl wait --for=condition=available --timeout=180s deployment/agent -n team1 -``` - -Verify the injected containers: - -```bash -kubectl get pod -n team1 -l app=agent -o jsonpath='{.items[0].spec.containers[*].name}' -# Expected (separate mode, with SPIFFE): agent spiffe-helper kagenti-client-registration envoy-proxy -# Expected (separate mode, without SPIFFE): agent kagenti-client-registration envoy-proxy -# Expected (combined mode): agent authbridge -``` - -## Step 3: Test the Flow - -These tests verify both **inbound** JWT validation and **outbound** token exchange end-to-end. By sending requests from outside the agent pod, each request exercises the full pipeline: - -1. **Inbound**: Envoy intercepts the incoming request, ext-proc validates the JWT (signature + issuer) -2. **Outbound**: auth-proxy forwards to auth-target, Envoy intercepts the outgoing request, ext-proc exchanges the token - -### Setup - -```bash -# Start a test client pod (sends requests from outside the agent pod) -kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 -kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s - -# Get the agent's client credentials from the sidecar container with the shared volume. -# Use -c authbridge in combined mode, or -c envoy-proxy in separate mode. -CLIENT_ID=$(kubectl exec deployment/agent -n team1 -c envoy-proxy -- cat /shared/client-id.txt) -CLIENT_SECRET=$(kubectl exec deployment/agent -n team1 -c envoy-proxy -- cat /shared/client-secret.txt) -echo "Client ID: $CLIENT_ID" - -# Get a service account token (using test-client which has curl) -TOKEN=$(kubectl exec test-client -n team1 -- curl -s -X POST \ - "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ - -d "grant_type=client_credentials" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token') - -# Get a user token for alice (for subject preservation test) -USER_TOKEN=$(kubectl exec test-client -n team1 -- curl -s -X POST \ - "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ - -d "grant_type=password" \ - -d "client_id=$CLIENT_ID" \ - -d "client_secret=$CLIENT_SECRET" \ - -d "username=alice" \ - -d "password=alice123" | jq -r '.access_token') -``` - -### 5a. Inbound Rejection - No Token - -```bash -kubectl exec test-client -n team1 -- curl -s http://agent-service:8080/test -# Expected: {"error":"unauthorized","message":"missing Authorization header"} -``` - -### 5b. Inbound Rejection - Invalid Token - -```bash -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer invalid-token" http://agent-service:8080/test -# Expected: {"error":"unauthorized","message":"token validation failed: ..."} -``` - -### 5c. End-to-End with Service Account Token - -Inbound validation passes, outbound token exchange converts `aud: ` → `aud: auth-target`: - -```bash -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer $TOKEN" http://agent-service:8080/test -# Expected: "authorized" -``` - -### 5d. End-to-End with User Token (Subject Preservation) - -Same as 5c, but using alice's user token. The `sub` and `preferred_username` claims are preserved through token exchange: - -```bash -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer $USER_TOKEN" http://agent-service:8080/test -# Expected: "authorized" -``` - -### Clean Up - -```bash -kubectl delete pod test-client -n team1 --ignore-not-found -``` - -### Quick Test Commands - -Run all tests as a single script: - -```bash -kubectl run test-client --image=nicolaka/netshoot -n team1 --restart=Never -- sleep 3600 2>/dev/null -kubectl wait --for=condition=ready pod/test-client -n team1 --timeout=30s - -CLIENT_ID=$(kubectl exec deployment/agent -n team1 -c envoy-proxy -- cat /shared/client-id.txt) -CLIENT_SECRET=$(kubectl exec deployment/agent -n team1 -c envoy-proxy -- cat /shared/client-secret.txt) - -TOKEN=$(kubectl exec test-client -n team1 -- curl -s -X POST \ - "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ - -d "grant_type=client_credentials" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" | jq -r '.access_token') - -USER_TOKEN=$(kubectl exec test-client -n team1 -- curl -s -X POST \ - "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" \ - -d "grant_type=password" -d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" \ - -d "username=alice" -d "password=alice123" | jq -r '.access_token') - -echo "=== 5a. No Token (expect 401) ===" -kubectl exec test-client -n team1 -- curl -s http://agent-service:8080/test -echo "" - -echo "=== 5b. Invalid Token (expect 401) ===" -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer invalid-token" http://agent-service:8080/test -echo "" - -echo "=== 5c. Service Account Token (expect authorized) ===" -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer $TOKEN" http://agent-service:8080/test -echo "" - -echo "=== 5d. User Token - alice (expect authorized) ===" -kubectl exec test-client -n team1 -- curl -s -H "Authorization: Bearer $USER_TOKEN" http://agent-service:8080/test -echo "" - -kubectl delete pod test-client -n team1 --ignore-not-found -``` - -## Troubleshooting - -### Check Pod Status - -```bash -kubectl get pods -n team1 -kubectl describe pod -l app=agent -n team1 -``` - -### Check Container Logs - -**proxy-sidecar mode** (default): -```bash -# All sidecar logs are in one container — the combined image bundles -# spiffe-helper internally, and operator-managed client registration -# logs live in the kagenti-operator deployment in kagenti-system. -kubectl logs deployment/agent -n team1 -c authbridge-proxy -kubectl logs deployment/agent -n team1 -c authbridge-proxy | grep -iE "token.exchange|error" -``` - -**envoy-sidecar mode**: -```bash -kubectl logs deployment/agent -n team1 -c envoy-proxy -kubectl logs deployment/agent -n team1 -c envoy-proxy | grep -iE "token.exchange|error" -# Operator-managed registration: -kubectl logs deployment/kagenti-controller-manager -n kagenti-system | grep -i clientregistration -``` - -### Common Issues - -1. **"Requested audience not available: auth-target"** - - Ensure the route entry in `authproxy-routes` includes `auth-target-aud` in `token_scopes` - - Run `setup_keycloak.py` again to create the required scopes - -2. **ConfigMap not found errors** - - Apply `k8s/configmaps-webhook.yaml` to the target namespace - -3. **Image pull errors** - - Images are automatically pulled from `ghcr.io/kagenti/kagenti-extensions/` - - If you need to build locally for development, see `local-build-and-test.sh` - at the repo root, which builds the post-#411 image set - (`authbridge`, `authbridge-envoy`, `authbridge-lite`, `proxy-init`) - and loads them into the cluster. - - See the [kagenti-operator](https://github.com/kagenti/kagenti-operator) for image configuration - -4. **SPIFFE credentials not ready** - - Ensure SPIRE is deployed and the workload is registered - - Check spiffe-helper logs for connection issues - -## Cleanup - -To remove all resources created during this demo: - -### 1. Delete Deployments and Services - -```bash -# Delete agent and auth-target deployments -kubectl delete deployment agent -n team1 -kubectl delete deployment auth-target -n team1 -kubectl delete service auth-target-service -n team1 -kubectl delete serviceaccount agent -n team1 -``` - -### 2. Delete ConfigMaps - -```bash -kubectl delete configmap authbridge-config -n team1 -kubectl delete configmap envoy-config -n team1 -kubectl delete configmap spiffe-helper-config -n team1 -``` - -### 3. Delete Keycloak Resources (Optional) - -If you want to clean up Keycloak clients and scopes: - -```bash -# Get admin token -ADMIN_TOKEN=$(curl -s http://keycloak.localtest.me:8080/realms/master/protocol/openid-connect/token \ - -d "grant_type=password" \ - -d "client_id=admin-cli" \ - -d "username=admin" \ - -d "password=admin" | jq -r ".access_token") - -# Delete the dynamically registered agent client -CLIENT_ID="spiffe://localtest.me/ns/team1/sa/agent" -INTERNAL_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients?clientId=$CLIENT_ID" | jq -r ".[0].id") -curl -s -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients/$INTERNAL_ID" - -# Delete auth-target client -AUTH_TARGET_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients?clientId=auth-target" | jq -r ".[0].id") -curl -s -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/clients/$AUTH_TARGET_ID" - -# Delete demo user alice -ALICE_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/users?username=alice" | jq -r ".[0].id") -curl -s -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \ - "http://keycloak.localtest.me:8080/admin/realms/kagenti/users/$ALICE_ID" - -echo "Keycloak resources cleaned up" -``` - -### 4. Delete Namespace (Optional) - -If you created a dedicated namespace for this demo: - -```bash -# This will delete everything in the namespace -kubectl delete namespace team1 -``` - -### 5. Remove Webhook (Optional) - -To remove the webhook, see the [kagenti-operator](https://github.com/kagenti/kagenti-operator) uninstall instructions. - -### Quick Cleanup (Delete Everything) - -For a complete cleanup including the namespace: - -```bash -# Delete namespace (removes all resources inside) -kubectl delete namespace team1 -``` diff --git a/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml b/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml deleted file mode 100644 index e7d9f41f5..000000000 --- a/authbridge/demos/webhook/k8s/agent-deployment-webhook-no-spiffe.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# Agent Deployment for AuthBridge Webhook Demo (No SPIFFE) -# -# This is a simplified version that doesn't require SPIRE. -# The agent uses a static client ID instead of SPIFFE ID. -# -# This deployment uses the kagenti-operator webhook to automatically inject -# a single combined AuthBridge sidecar (post-#411). Without SPIRE the -# bundled spiffe-helper stays inactive (SPIRE_ENABLED=false); the operator -# still registers a Keycloak client using a static name and mounts the -# resulting Secret at /shared/client-{id,secret}.txt. Container shape: -# - proxy-sidecar (default): one container, "authbridge-proxy". -# - envoy-sidecar: "proxy-init" init container + "envoy-proxy" container. -# -# The agent container runs auth-proxy, which: -# - Listens on port 8080 -# - Forwards requests to auth-target-service:8081 -# - JWT validation is handled by the inbound AuthBridge sidecar (injected by webhook) -# - Token exchange is handled by the outbound AuthBridge sidecar (injected by webhook) -# -# Labels (must be on Pod template, not just Deployment): -# kagenti.io/type: agent - Required: identifies this as an agent workload -# kagenti.io/inject: enabled - Enables AuthBridge sidecar injection -# kagenti.io/spire: disabled - Disables SPIFFE-based identity (uses static client ID) -# -# Usage: -# kubectl apply -f agent-deployment-webhook-no-spiffe.yaml - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: team1 - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent - namespace: team1 - labels: - app: agent -spec: - replicas: 1 - selector: - matchLabels: - app: agent - template: - metadata: - labels: - app: agent - kagenti.io/type: agent - kagenti.io/inject: enabled - kagenti.io/spire: disabled - spec: - serviceAccountName: agent - containers: - # auth-proxy - pass-through proxy (JWT validation handled by inbound ext-proc) - - name: agent - image: ghcr.io/kagenti/kagenti-extensions/auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - volumeMounts: - - name: shared-data - mountPath: /shared - volumes: - - name: shared-data - emptyDir: {} - ---- -apiVersion: v1 -kind: Service -metadata: - name: agent-service - namespace: team1 -spec: - selector: - app: agent - ports: - - port: 8080 - targetPort: 8080 - protocol: TCP - type: ClusterIP diff --git a/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml b/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml deleted file mode 100644 index f46358eb7..000000000 --- a/authbridge/demos/webhook/k8s/agent-deployment-webhook.yaml +++ /dev/null @@ -1,95 +0,0 @@ -# Agent Deployment for AuthBridge Webhook Demo -# -# This deployment uses the kagenti-operator webhook to automatically inject -# a single combined AuthBridge sidecar (post-#411). The exact shape depends -# on the resolved AuthBridge mode: -# - proxy-sidecar (default): one container, "authbridge-proxy", from the -# "authbridge" image. spiffe-helper is bundled inside; gated by SPIRE_ENABLED. -# - envoy-sidecar: a "proxy-init" init container (iptables) plus one -# container, "envoy-proxy", from the "authbridge-envoy" image (Envoy + -# ext_proc + bundled spiffe-helper). -# Keycloak client registration is operator-managed (no in-pod sidecar); -# the webhook mounts the resulting Secret at /shared/client-{id,secret}.txt. -# -# The agent container runs auth-proxy, which: -# - Listens on port 8080 -# - Forwards requests to auth-target-service:8081 -# - JWT validation is handled by the inbound AuthBridge sidecar (injected by webhook) -# - Token exchange is handled by the outbound AuthBridge sidecar (injected by webhook) -# -# Labels (must be on Pod template, not just Deployment): -# kagenti.io/type: agent - Required: identifies this as an agent workload -# kagenti.io/inject: enabled - Enables AuthBridge sidecar injection -# kagenti.io/spire: enabled - Enables SPIRE-based identity (optional) -# -# Usage: -# kubectl apply -f agent-deployment-webhook.yaml - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: agent - namespace: team1 - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent - namespace: team1 - labels: - app: agent -spec: - replicas: 1 - selector: - matchLabels: - app: agent - template: - metadata: - labels: - app: agent - kagenti.io/type: agent - kagenti.io/inject: enabled - kagenti.io/spire: enabled - spec: - serviceAccountName: agent - containers: - # auth-proxy - pass-through proxy (JWT validation handled by inbound ext-proc) - - name: agent - image: ghcr.io/kagenti/kagenti-extensions/auth-proxy:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8080 - name: http - env: - - name: TARGET_SERVICE_URL - value: "http://auth-target-service:8081" - resources: - limits: - cpu: 500m - memory: 512Mi - requests: - cpu: 100m - memory: 128Mi - volumeMounts: - - name: shared-data - mountPath: /shared - volumes: - - name: shared-data - emptyDir: {} - ---- -apiVersion: v1 -kind: Service -metadata: - name: agent-service - namespace: team1 -spec: - selector: - app: agent - ports: - - port: 8080 - targetPort: 8080 - protocol: TCP - type: ClusterIP diff --git a/authbridge/demos/webhook/k8s/auth-target-deployment-webhook.yaml b/authbridge/demos/webhook/k8s/auth-target-deployment-webhook.yaml deleted file mode 100644 index faacbdf7a..000000000 --- a/authbridge/demos/webhook/k8s/auth-target-deployment-webhook.yaml +++ /dev/null @@ -1,67 +0,0 @@ -# Auth Target Deployment for AuthBridge Webhook Demo -# -# This is the target service that validates exchanged tokens. -# It does NOT have AuthBridge injection enabled (kagenti.io/inject: disabled) -# because it's the destination service, not the caller. -# -# The auth-target validates that incoming tokens have: -# - aud: auth-target -# - Valid signature from Keycloak -# -# Usage: -# kubectl apply -f auth-target-deployment-webhook.yaml - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: auth-target - namespace: team1 - labels: - app: auth-target - kagenti.io/inject: disabled # Do NOT inject AuthBridge sidecars -spec: - replicas: 1 - selector: - matchLabels: - app: auth-target - template: - metadata: - labels: - app: auth-target - spec: - containers: - - name: auth-target - image: ghcr.io/kagenti/kagenti-extensions/demo-app:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 8081 - env: - - name: ISSUER - value: "http://keycloak.localtest.me:8080/realms/kagenti" - - name: JWKS_URL - value: "http://keycloak-service.keycloak.svc.cluster.local:8080/realms/kagenti/protocol/openid-connect/certs" - - name: AUDIENCE - value: "auth-target" - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - ---- -apiVersion: v1 -kind: Service -metadata: - name: auth-target-service - namespace: team1 -spec: - selector: - app: auth-target - ports: - - port: 8081 - targetPort: 8081 - protocol: TCP - type: ClusterIP diff --git a/authbridge/demos/webhook/k8s/configmaps-webhook.yaml b/authbridge/demos/webhook/k8s/configmaps-webhook.yaml deleted file mode 100644 index 536a1deef..000000000 --- a/authbridge/demos/webhook/k8s/configmaps-webhook.yaml +++ /dev/null @@ -1,244 +0,0 @@ -# ConfigMaps required for AuthBridge webhook injection -# Apply these to any namespace where you want to use AuthBridge with the webhook -# -# Usage: -# kubectl apply -f configmaps-webhook.yaml -n -# -# Note: Update the namespace in metadata if applying to a different namespace - ---- -# keycloak-admin-secret - Keycloak admin credentials for client registration -# Replace the placeholder values with your actual admin credentials. -apiVersion: v1 -kind: Secret -metadata: - name: keycloak-admin-secret - namespace: team1 -type: Opaque -stringData: - KEYCLOAK_ADMIN_USERNAME: "admin" - KEYCLOAK_ADMIN_PASSWORD: "admin" - ---- -# authbridge-config ConfigMap - Unified config for both client-registration and envoy-proxy (authbridge) -# -# client-registration reads: KEYCLOAK_URL, KEYCLOAK_REALM, PLATFORM_CLIENT_IDS -# authbridge reads: KEYCLOAK_URL, KEYCLOAK_REALM (to derive TOKEN_URL/ISSUER), TOKEN_URL, ISSUER, -# DEFAULT_OUTBOUND_POLICY -apiVersion: v1 -kind: ConfigMap -metadata: - name: authbridge-config - namespace: team1 -data: - KEYCLOAK_URL: "http://keycloak-service.keycloak.svc:8080" - KEYCLOAK_REALM: "kagenti" - # TOKEN_URL: Optional. Auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM if not set. - # Only set explicitly when the token endpoint differs from the standard Keycloak path. - # TOKEN_URL: "http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token" - # ISSUER: Optional when KEYCLOAK_URL points to the frontend. Auto-derived from - # KEYCLOAK_URL + KEYCLOAK_REALM if not set. Set explicitly when the internal - # KEYCLOAK_URL differs from the frontend URL that appears in token "iss" claims - # (split-horizon DNS). - ISSUER: "http://keycloak.localtest.me:8080/realms/kagenti" - # Audience validation is automatic — uses CLIENT_ID from /shared/client-id.txt - # (written by client-registration or operator). No manual configuration needed. - # PLATFORM_CLIENT_IDS: Comma-separated list of Keycloak client IDs that should - # receive the agent's audience scope. Default: "kagenti" - # PLATFORM_CLIENT_IDS: "kagenti" - # DEFAULT_OUTBOUND_POLICY: Controls behavior for outbound requests that don't - # match any route in authproxy-routes. Default is "passthrough" (skip exchange). - # Set to "exchange" to restore legacy behavior (exchange all outbound traffic). - # DEFAULT_OUTBOUND_POLICY: "passthrough" - ---- -# authproxy-routes ConfigMap - Per-target token exchange routes -# -# Defines which outbound hosts should get token exchange. All other hosts pass -# through unchanged when DEFAULT_OUTBOUND_POLICY is "passthrough" (the default). -# -# This ConfigMap is required for outbound token exchange to work. Without it, -# no outbound token exchange occurs (all traffic passes through). Each route -# specifies the target_audience and token_scopes for a given host. -apiVersion: v1 -kind: ConfigMap -metadata: - name: authproxy-routes - namespace: team1 -data: - routes.yaml: | - - host: "auth-target-service" - target_audience: "auth-target" - token_scopes: "openid auth-target-aud" - ---- -# spiffe-helper-config ConfigMap - Used by spiffe-helper container (SPIRE mode only) -apiVersion: v1 -kind: ConfigMap -metadata: - name: spiffe-helper-config - namespace: team1 -data: - helper.conf: | - agent_address = "/spiffe-workload-api/spire-agent.sock" - cmd = "" - cmd_args = "" - cert_dir = "/opt" - renew_signal = "" - svid_file_name = "svid.pem" - svid_key_file_name = "svid_key.pem" - svid_bundle_file_name = "svid_bundle.pem" - jwt_svids = [{jwt_audience="kagenti", jwt_svid_file_name="jwt_svid.token"}] - ---- -# envoy-config ConfigMap - Envoy proxy configuration for traffic interception -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - namespace: team1 -data: - envoy.yaml: | - admin: - address: - socket_address: - protocol: TCP - address: 127.0.0.1 - port_value: 9901 - - static_resources: - listeners: - - name: outbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15123 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - - name: envoy.filters.listener.tls_inspector - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector - filter_chains: - # TLS passthrough: forward HTTPS traffic as-is via TCP proxy - - filter_chain_match: - transport_protocol: tls - filters: - - name: envoy.filters.network.tcp_proxy - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy - stat_prefix: outbound_tls_passthrough - cluster: original_destination - # Plaintext HTTP: inspect and process via ext_proc - - filter_chain_match: - transport_protocol: raw_buffer - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: outbound_http - codec_type: AUTO - route_config: - name: outbound_routes - virtual_hosts: - - name: catch_all - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SEND - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - - name: inbound_listener - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 15124 - listener_filters: - - name: envoy.filters.listener.original_dst - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: inbound_http - codec_type: AUTO - route_config: - name: inbound_routes - virtual_hosts: - - name: local_app - domains: ["*"] - routes: - - match: - prefix: "/" - route: - cluster: original_destination - http_filters: - - name: envoy.filters.http.lua - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua - inline_code: | - function envoy_on_request(request_handle) - request_handle:headers():add("x-authbridge-direction", "inbound") - end - - name: envoy.filters.http.ext_proc - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor - grpc_service: - envoy_grpc: - cluster_name: ext_proc_cluster - timeout: 30s - allow_mode_override: true - processing_mode: - request_header_mode: SEND - response_header_mode: SEND - request_body_mode: NONE - response_body_mode: NONE - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router - - clusters: - - name: original_destination - connect_timeout: 30s - type: ORIGINAL_DST - lb_policy: CLUSTER_PROVIDED - original_dst_lb_config: - use_http_header: false - - - name: ext_proc_cluster - connect_timeout: 5s - type: STATIC - lb_policy: ROUND_ROBIN - http2_protocol_options: {} - load_assignment: - cluster_name: ext_proc_cluster - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: 127.0.0.1 - port_value: 9090 diff --git a/authbridge/demos/webhook/setup_keycloak.py b/authbridge/demos/webhook/setup_keycloak.py deleted file mode 100644 index 556a5534e..000000000 --- a/authbridge/demos/webhook/setup_keycloak.py +++ /dev/null @@ -1,442 +0,0 @@ -""" -setup_keycloak.py - Keycloak Setup for AuthBridge Webhook - -This script configures Keycloak for deployments using the kagenti-operator -to inject AuthBridge sidecars. Unlike the standalone demo, this setup supports -any namespace where the operator webhook is enabled. - -Usage: - python setup_keycloak.py [--namespace NAMESPACE] [--service-account SA] - -Examples: - # Default: team1 namespace, agent service account - python setup_keycloak.py - - # Custom namespace and service account - python setup_keycloak.py --namespace myapp --service-account mysa - -Architecture: - Workload with label 'kagenti.io/inject: enabled' - ↓ - Webhook injects: proxy-init, spiffe-helper, client-registration, envoy-proxy - ↓ - Client Registration registers workload with Keycloak using SPIFFE ID - ↓ - Envoy intercepts outgoing requests and exchanges tokens - -Clients created: -- auth-target: Target audience for token exchange (required by Keycloak) - -Client Scopes created: -- agent-spiffe-aud: Adds Agent's SPIFFE ID to token audience (realm DEFAULT) -- auth-target-aud: Adds "auth-target" to token audience (realm OPTIONAL) - -Demo Users created: -- alice: Demo user to demonstrate subject preservation (username: alice, password: alice123) - -Security Note: -- This script uses default Keycloak admin credentials (username: "admin", password: "admin") - for demo and local development only. These credentials are insecure and MUST NOT be used - in any production or internet-exposed environment. Always override them via environment - variables or other secure configuration when running outside a demo context. -""" - -import argparse -import os -import sys - -from keycloak import KeycloakAdmin, KeycloakPostError - -# Default configuration -# NOTE: The default admin credentials below ("admin"/"admin") are for demo and local -# development purposes only and must not be used in production. Override them with -# environment variables when running in any non-demo environment. -KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "http://keycloak.localtest.me:8080") -KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "kagenti") -KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") -KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") - -# Emit a warning if the insecure demo credentials are in use. -if KEYCLOAK_ADMIN_USERNAME == "admin" and KEYCLOAK_ADMIN_PASSWORD == "admin": - print( - "WARNING: Using default Keycloak admin credentials 'admin'/'admin'. " - "These credentials are INSECURE and must NOT be used in production.", - file=sys.stderr, - ) -# Default namespace and service account for webhook deployments -DEFAULT_NAMESPACE = "team1" -DEFAULT_SERVICE_ACCOUNT = "agent" -SPIFFE_TRUST_DOMAIN = "localtest.me" - -# Demo user for demonstrating subject preservation -DEMO_USER = { - "username": "alice", - "email": "alice@example.com", - "firstName": "Alice", - "lastName": "Demo", - "password": "alice123", -} - - -def get_spiffe_id(namespace: str, service_account: str) -> str: - """Generate SPIFFE ID for a given namespace and service account.""" - return f"spiffe://{SPIFFE_TRUST_DOMAIN}/ns/{namespace}/sa/{service_account}" - - -def get_or_create_realm(keycloak_admin, realm_name): - """Create realm if it doesn't exist.""" - try: - realms = keycloak_admin.get_realms() - for realm in realms: - 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, - } - ) - print(f"Created realm '{realm_name}'.") - except Exception as e: - print(f"Error checking/creating realm: {e}") - - -def get_or_create_client(keycloak_admin, client_payload): - """Create client if doesn't exist, return internal client ID.""" - 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.") - return existing_client_id - internal_id = keycloak_admin.create_client(client_payload) - print(f"Created client '{client_id}'.") - return internal_id - - -def get_or_create_client_scope(keycloak_admin, scope_payload): - """Create client scope if doesn't exist, return scope ID.""" - scope_name = scope_payload.get("name") - scopes = keycloak_admin.get_client_scopes() - for scope in scopes: - if scope["name"] == scope_name: - print(f"Client scope '{scope_name}' already exists with ID: {scope['id']}") - return scope["id"] - - try: - scope_id = keycloak_admin.create_client_scope(scope_payload) - print(f"Created client scope '{scope_name}': {scope_id}") - return scope_id - except KeycloakPostError as e: - print(f"Could not create client scope '{scope_name}': {e}") - raise - - -def add_audience_mapper(keycloak_admin, scope_id, mapper_name, audience): - """Add audience protocol mapper to a client scope.""" - mapper_payload = { - "name": mapper_name, - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": False, - "config": { - "included.custom.audience": audience, - "id.token.claim": "false", - "access.token.claim": "true", - "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: - # Mapper might already exist - print(f"Note: Could not add mapper '{mapper_name}' (might already exist): {e}") - - -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}], - } - ) - print(f"Created user '{username}' with ID: {user_id}") - return user_id - except KeycloakPostError as e: - print(f"Could not create user '{username}': {e}") - raise - - -def main(): - parser = argparse.ArgumentParser(description="Setup Keycloak for AuthBridge webhook deployments") - parser.add_argument( - "--namespace", - "-n", - default=DEFAULT_NAMESPACE, - help=f"Kubernetes namespace for the agent (default: {DEFAULT_NAMESPACE})", - ) - parser.add_argument( - "--service-account", - "-s", - default=DEFAULT_SERVICE_ACCOUNT, - help=f"Service account name for the agent (default: {DEFAULT_SERVICE_ACCOUNT})", - ) - args = parser.parse_args() - - namespace = args.namespace - service_account = args.service_account - agent_spiffe_id = get_spiffe_id(namespace, service_account) - - print("=" * 70) - print("AuthBridge Webhook - Keycloak Setup") - print("=" * 70) - 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: - master_admin = KeycloakAdmin( - server_url=KEYCLOAK_URL, - username=KEYCLOAK_ADMIN_USERNAME, - password=KEYCLOAK_ADMIN_PASSWORD, - realm_name="master", - user_realm_name="master", - ) - except Exception as e: - print(f"Failed to connect to Keycloak: {e}") - print("\nMake sure Keycloak is running and accessible at:") - print(f" {KEYCLOAK_URL}") - 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", - ) - - # 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"}, - }, - ) - - # 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"}, - }, - ) - 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"}, - }, - ) - 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) - print(f""" -Create these ConfigMaps in the {namespace} namespace: - -# 1. authbridge-config ConfigMap (for both client-registration and envoy-proxy) -# TOKEN_URL and ISSUER are auto-derived from KEYCLOAK_URL + KEYCLOAK_REALM. -# Set ISSUER explicitly only when the internal URL differs from the frontend URL. -# Audience validation uses CLIENT_ID from /shared/client-id.txt automatically. -kubectl create configmap authbridge-config -n {namespace} \\ - --from-literal=KEYCLOAK_URL=http://keycloak-service.keycloak.svc:8080 \\ - --from-literal=KEYCLOAK_REALM=kagenti \\ - --from-literal=ISSUER=http://keycloak.localtest.me:8080/realms/kagenti - -# 2. keycloak-admin-secret Secret (for client-registration) -kubectl create secret generic keycloak-admin-secret -n {namespace} \\ - --from-literal=KEYCLOAK_ADMIN_USERNAME=admin \\ - --from-literal=KEYCLOAK_ADMIN_PASSWORD=admin - -# 3. spiffe-helper-config ConfigMap (for SPIRE-enabled mode) -kubectl apply -f - </dev/null || true diff --git a/authbridge/proxy-init/README.md b/authbridge/proxy-init/README.md new file mode 100644 index 000000000..eeb263f15 --- /dev/null +++ b/authbridge/proxy-init/README.md @@ -0,0 +1,73 @@ +# proxy-init + +The `proxy-init` container sets up iptables rules so that traffic in +and out of an AuthBridge-injected pod is transparently redirected to +the AuthBridge sidecar's listeners. It runs once at pod startup as a +Kubernetes init container, then exits. + +**`proxy-init` is only used in `envoy-sidecar` mode.** In +`proxy-sidecar` mode (the AuthBridge cluster default after +kagenti-operator#361) traffic interception is done via `HTTP_PROXY` +env vars on the workload container — no iptables, no init container. + +## What it does + +`init-iptables.sh` writes iptables rules that: + +- **Outbound** — Redirect traffic leaving the workload container to + AuthBridge's outbound listener (port 15123). Adds an exclusion for + the AuthBridge sidecar's own UID (1337) so its traffic doesn't loop + back into itself. +- **Inbound** — Redirect traffic arriving at the workload container's + service port to AuthBridge's inbound listener (port 15124). +- **Istio ambient coexistence** — Cooperates with ztunnel by + preserving the Istio fwmark (0x539) and respecting the HBONE port + (15008). Designed to work alongside `istio.io/dataplane-mode: + ambient`. +- **Configurable exclusions** — Honors `OUTBOUND_PORTS_EXCLUDE` and + `INBOUND_PORTS_EXCLUDE` env vars (commonly used to exclude + Keycloak's port 8080 to avoid token-exchange loops). + +The script auto-detects `iptables-legacy` vs `iptables-nft` and uses +whichever the host kernel exposes. Override with `IPTABLES_CMD` if +needed. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `PROXY_PORT` | `15123` | AuthBridge outbound listener port | +| `INBOUND_PROXY_PORT` | `15124` | AuthBridge inbound listener port | +| `PROXY_UID` | `1337` | UID of the AuthBridge sidecar process; excluded from outbound redirect | +| `OUTBOUND_PORTS_EXCLUDE` | (empty) | Comma-separated outbound port list to skip (e.g. `8080`) | +| `INBOUND_PORTS_EXCLUDE` | (empty) | Comma-separated inbound port list to skip | +| `POD_IP` | (required) | Set via Downward API; used as the DNAT target for ambient-mesh inbound | +| `IPTABLES_CMD` | auto-detected | Override iptables binary (`iptables-legacy` / `iptables-nft`) | + +## Required Kubernetes capabilities + +The container needs `NET_ADMIN` and `NET_RAW` capabilities and runs as +UID 0 — but **not** privileged mode. The kagenti-operator's webhook +sets up the SecurityContext correctly when injecting the init +container. + +## Building + +```sh +make docker-build-init +make load-image # load into a kind cluster +``` + +The image is published from CI as +`ghcr.io/kagenti/kagenti-extensions/proxy-init:` (build defined +in [`.github/workflows/build.yaml`](../../.github/workflows/build.yaml)). + +## Where it gets injected + +The kagenti-operator's mutating webhook injects the proxy-init +container automatically when the resolved AuthBridge mode is +`envoy-sidecar`. See +[`authbridge/demos/weather-agent/demo-ui-advanced.md`](../demos/weather-agent/demo-ui-advanced.md) +for an end-to-end demo and +[`authbridge/demos/token-exchange-routes/README.md`](../demos/token-exchange-routes/README.md) +for the route-config reference. diff --git a/authbridge/authproxy/init-iptables.sh b/authbridge/proxy-init/init-iptables.sh similarity index 100% rename from authbridge/authproxy/init-iptables.sh rename to authbridge/proxy-init/init-iptables.sh diff --git a/local-build-and-test.sh b/local-build-and-test.sh index 90ea8b829..40e638b23 100755 --- a/local-build-and-test.sh +++ b/local-build-and-test.sh @@ -103,7 +103,7 @@ echo "" echo "==========================================" echo "Building proxy-init" echo "==========================================" -cd "${SCRIPT_DIR}/authbridge/authproxy" +cd "${SCRIPT_DIR}/authbridge/proxy-init" ${CONTAINER_RUNTIME} build -f Dockerfile.init -t ghcr.io/kagenti/kagenti-extensions/proxy-init:local . load_image_to_kind ghcr.io/kagenti/kagenti-extensions/proxy-init:local echo "✅ Built and loaded: proxy-init:local" From 55bd738367f1376141dd56aa8c45360410caf0e7 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 20:20:46 -0400 Subject: [PATCH 8/9] chore: address PR #414 review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three suggestions from the reviewer, all applied: * `make build-images` rename — root Makefile's `build-images` target was renamed to `build-proxy-init` in commit 0416172 but four docs (README.md, CONTRIBUTING.md, CLAUDE.md, authbridge/ CLAUDE.md) still invoked the old name. Update each: - README.md: bare `make build-images` → `make build-proxy-init` (the root target now builds only proxy-init, not "all images"; naming matches reality). - CONTRIBUTING.md, CLAUDE.md: `cd authbridge/proxy-init && make build-images` → `make docker-build-init` (the proxy-init/ Makefile only has `docker-build-init` and `load-image` targets; there is no `build-images` target there). - authbridge/CLAUDE.md: the rebuild test snippet pointed at `make build-images && make load-images`; replace with the `cd authbridge && podman build -f cmd/...` recipe that actually rebuilds the affected combined image — the touched code under `authlib/exchange/` ends up in every cmd/* binary, not just proxy-init. Also add a top-level "Building Everything Locally" section that points at the repo-root local-build-and-test.sh as the orchestrated path, with per-image podman commands as the manual fallback. * proxy-init/Makefile container-runtime hardcoded to podman — local-build-and-test.sh in this same PR auto-detects docker vs podman; the Makefile didn't. Add CONTAINER_RUNTIME ?= $(shell command -v podman >/dev/null 2>&1 \ && echo podman || echo docker) so docker users get docker by default. The $(CONTAINER_RUNTIME) build -f Dockerfile.init … line then works for either. Override with make CONTAINER_RUNTIME=docker docker-build-init on systems with both installed. * proxy-init/Makefile silent-error swallow on the podman ctr retag — the `2>/dev/null || true` masked "podman not found" on docker-only systems, producing a successful-looking `make load-image` followed by ImagePullBackOff at runtime. Replace the unconditional retag with an `ifeq ($(CONTAINER_RUNTIME), podman) … endif` block that only runs when we know we're using podman, and drop the `2>/dev/null || true` so a real failure surfaces. Docker doesn't need the retag (kind resolves docker images by their full name natively), so this is a clean per-runtime split. Verified: `make -n` renders the right commands for both CONTAINER_RUNTIME=podman (default on machines with podman) and CONTAINER_RUNTIME=docker (explicit override). Repo-wide grep for `make build-images` returns zero hits after the doc updates. The PR description point from the same review is addressed separately by editing the PR body — no code change needed. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 20 +++++++++++++++++--- CONTRIBUTING.md | 6 ++++-- README.md | 4 ++-- authbridge/CLAUDE.md | 5 ++++- authbridge/proxy-init/Makefile | 22 +++++++++++++++------- 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80906601b..547c09b68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,11 +229,25 @@ When the operator injects sidecars, the target namespace needs these resources: ### Building Everything Locally +The repo-root `local-build-and-test.sh` orchestrates every image +the platform needs (`spiffe-idp-setup` from kagenti, plus +`authbridge`, `authbridge-envoy`, `authbridge-lite`, `proxy-init` +from this repo) and loads them into a Kind cluster: + +```bash +KAGENTI_DIR=../kagenti ./local-build-and-test.sh +``` + +To build a single image directly: + ```bash -# AuthProxy images -cd authbridge/proxy-init && make build-images +# proxy-init (iptables init container, envoy-sidecar mode) +cd authbridge/proxy-init && make docker-build-init -# Client registration (no separate build needed, uses Dockerfile directly) +# Combined sidecars (proxy-sidecar default / envoy-sidecar / lite) +cd authbridge && podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . +cd authbridge && podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . +cd authbridge && podman build -f cmd/authbridge-lite/Dockerfile -t authbridge-lite:latest . ``` ### Running the Full Demo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 441f5ead0..a3337b2a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,10 @@ cd kagenti-extensions # Install pre-commit hooks pre-commit install -# Build AuthProxy images -cd authbridge/proxy-init && make build-images +# Build the proxy-init image (one-target Makefile in proxy-init/). +# For the four combined-sidecar images plus this one, use the +# repo-root local-build-and-test.sh. +cd authbridge/proxy-init && make docker-build-init ``` ## Issues diff --git a/README.md b/README.md index bbb14b44a..8bc559960 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ make pre-commit # Run formatters make fmt -# Build AuthProxy Docker images -make build-images +# Build the proxy-init iptables init container image +make build-proxy-init # Run local testing (requires Kind cluster) ./local-build-and-test.sh diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index 87d16d52c..e742edf37 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -417,7 +417,10 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h ### Modifying Token Exchange Logic - Edit `authlib/exchange/` -- the RFC 8693 token exchange client - The token exchange POST parameters follow RFC 8693 exactly -- Test by rebuilding: `make build-images && make load-images` +- Test by rebuilding the affected combined image (e.g., + `cd authbridge && podman build -f cmd/authbridge-envoy/Dockerfile + -t authbridge-envoy:latest .` then `kind load docker-image + authbridge-envoy:latest --name kagenti`). ### Modifying Inbound JWT Validation - Edit `authlib/validation/` -- the JWKS-backed JWT verifier diff --git a/authbridge/proxy-init/Makefile b/authbridge/proxy-init/Makefile index dd58fc553..da64777ca 100644 --- a/authbridge/proxy-init/Makefile +++ b/authbridge/proxy-init/Makefile @@ -2,17 +2,25 @@ KIND_CLUSTER_NAME ?= kagenti +# Container runtime — defaults to podman (the repo's primary toolchain) +# but auto-falls-through to docker if podman isn't installed. Override +# explicitly with `make CONTAINER_RUNTIME=docker docker-build-init`. +CONTAINER_RUNTIME ?= $(shell command -v podman >/dev/null 2>&1 && echo podman || echo docker) + # Build the proxy-init image (iptables init container, used by # AuthBridge envoy-sidecar mode for transparent traffic interception). docker-build-init: - podman build -f Dockerfile.init -t proxy-init:latest . + $(CONTAINER_RUNTIME) build -f Dockerfile.init -t proxy-init:latest . -# Load the locally-built proxy-init image into a Kind cluster and -# tag it under docker.io/library/ so Kubernetes can resolve the bare -# image name. (Podman tags as localhost/, but kubelet looks under -# docker.io/library/ for unqualified names.) +# Load the locally-built proxy-init image into a Kind cluster. Podman +# tags images under localhost/, but kubelet looks under +# docker.io/library/ for unqualified names — so on podman we need an +# extra `ctr images tag` inside the kind node. Docker doesn't have +# this problem; the second step is a no-op skipped by the `ifeq`. KIND_NODE ?= $(KIND_CLUSTER_NAME)-control-plane load-image: kind load docker-image proxy-init:latest --name $(KIND_CLUSTER_NAME) - podman exec $(KIND_NODE) ctr --namespace=k8s.io images tag \ - localhost/proxy-init:latest docker.io/library/proxy-init:latest 2>/dev/null || true +ifeq ($(CONTAINER_RUNTIME),podman) + $(CONTAINER_RUNTIME) exec $(KIND_NODE) ctr --namespace=k8s.io images tag \ + localhost/proxy-init:latest docker.io/library/proxy-init:latest +endif From 334042dfe5f814caba93ab314135608c3ab249a9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 15 May 2026 20:35:02 -0400 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20address=20PR=20#414=20review=20nits?= =?UTF-8?q?=20=E2=80=94=20finish=20the=20authbridge/README=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small followups missed in the earlier sweep. All in scope of the post-#411 cleanup; they survived because previous edits to the same files only touched the deployment-modes table + demos list, not the prose / diagrams / component-doc list. * `authbridge/README.md` line 3 — opening paragraph linked to two deleted/renamed paths: `[client registration](./client-registration/)` (no such directory in the repo) and `[token exchange](./authproxy/)` (renamed to `proxy-init/` in commit 0416172, and `proxy-init/` is just iptables — not a "token exchange" component anyway). Rewrite the paragraph to point at `authlib/` + `cmd/` for the actual code, with an explicit note that registration is now operator-managed. * `authbridge/README.md` ASCII architecture diagram — the box labeled `client-registration (registers Workload with Keycloak)` is gone (operator-managed), so drop that box. The "AuthProxy Sidecar" header was rewritten to "AuthBridge Sidecar (combined image)" with the per-mode container-name disambiguation (`authbridge-proxy` / `envoy-proxy`) we already established elsewhere. Spiffe-helper is also no longer a separate box — bundled inside the combined image, gated by `SPIRE_ENABLED`, noted inline. Add an out-of-band footer naming the operator's ClientRegistrationReconciler + the `kagenti-keycloak-client-credentials-` Secret it produces. * `authbridge/README.md` Mermaid architecture diagram — same treatment: collapse SpiffeHelper + ClientReg + Sidecar (which was three sub-nodes — auth-proxy + envoy-proxy + ext-proc) into one Sidecar node with the per-mode name. Move ClientRegistration into a new `Operator (kagenti-system)` subgraph so the diagram shows it living outside the workload pod. Renumber the request-flow arrows from 1-11 to 1-9 reflecting the simplified shape. * `authbridge/README.md` Workload Pod table — drop the `client-registration` row, and split the single `AuthProxy Sidecar` row into two per-mode rows (`authbridge-proxy` and `envoy-proxy`) with a `Mode` column. Add a brief lead-in paragraph explaining the post-#411 shape. * `authbridge/README.md` Component Documentation links — both `[AuthProxy](authproxy/README.md)` and `[Client Registration](client-registration/README.md)` 404 now. Replace with one bullet per mode-specific cmd/* binary plus proxy-init plus authlib plus a pointer at the `docs/` directory that holds the framework / plugin author references. Add a trailing note that registration is operator-managed (the `Client Registration` link's purpose). * `CLAUDE.md` lines 265-266 — the "Routes config reference" line appeared twice (once bare, once with the parenthetical). Drop the bare duplicate; keep the more informative one. Verified: every link in authbridge/README.md resolves; repo-wide grep confirms the only remaining `client-registration` mentions in this file are descriptive prose about the post-#411 migration ("standalone client-registration / spiffe-helper sidecars are gone"), not paths or component references. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 1 - authbridge/README.md | 97 ++++++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 547c09b68..0fc5c5263 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,7 +257,6 @@ cd authbridge && podman build -f cmd/authbridge-lite/Dockerfile -t authbridge-l 3. See the [AuthBridge demos index](authbridge/demos/README.md) for a recommended learning path: - **Getting started**: `authbridge/demos/weather-agent/demo-ui.md` (inbound validation, UI deployment) - **Full flow**: `authbridge/demos/github-issue/demo-ui.md` (token exchange + scope-based access) - - **Routes config reference**: `authbridge/demos/token-exchange-routes/README.md` - **Routes config reference**: `authbridge/demos/token-exchange-routes/README.md` (single + multi-target route patterns) ### Adding a New Component Image to CI diff --git a/authbridge/README.md b/authbridge/README.md index 1cbc98051..cc56baf5d 100644 --- a/authbridge/README.md +++ b/authbridge/README.md @@ -1,6 +1,6 @@ # AuthBridge -AuthBridge provides **secure, transparent token management** for Kubernetes workloads. It combines automatic [client registration](./client-registration/) with [token exchange](./authproxy/) capabilities, enabling zero-trust authentication flows with [SPIFFE/SPIRE](https://spiffe.io) integration. +AuthBridge provides **secure, transparent token management** for Kubernetes workloads. The shared library is at [`authlib/`](./authlib/); the mode-specific binaries (proxy-sidecar default, envoy-sidecar, lite) live under [`cmd/`](./cmd/). Keycloak client registration is handled by the [kagenti-operator](https://github.com/kagenti/kagenti-operator)'s `ClientRegistrationReconciler` (no in-pod registration sidecar). Together with [SPIFFE/SPIRE](https://spiffe.io), this enables zero-trust authentication flows. > **📘 Looking to run the demo?** See the [Weather Agent](./demos/weather-agent/demo-ui.md) or [GitHub Issue Agent](./demos/github-issue/demo.md) demos for step-by-step instructions, and [Token-Exchange Routes](./demos/token-exchange-routes/README.md) for route configuration. @@ -57,8 +57,10 @@ AuthBridge solves the challenge of **secure service-to-service authentication** │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ AuthProxy Sidecar │ │ -│ │ (Envoy + Ext Proc) │ │ +│ │ AuthBridge Sidecar (combined image) │ │ +│ │ Container name = mode-dependent: │ │ +│ │ proxy-sidecar (default): authbridge-proxy │ │ +│ │ envoy-sidecar: envoy-proxy │ │ │ │ │ │ │ │ INBOUND: Validates JWT (signature + issuer via JWKS) │ │ │ │ Returns 401 Unauthorized if invalid │ │ @@ -68,15 +70,17 @@ AuthBridge solves the challenge of **secure service-to-service authentication** │ ▲ outbound │ inbound │ │ │ request │ (validated) │ │ │ ▼ │ -│ ┌─────────┴────────────────────┐ ┌───────────────────────────────┐ │ -│ │ Your App │ │ SPIFFE Helper │ │ -│ │ │ │ (provides SPIFFE creds) │ │ -│ └──────────────────────────────┘ └───────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ client-registration (registers Workload with Keycloak) │ │ +│ ┌─────────┴───────────────────────────────────────────────────────┐ │ +│ │ Your App │ │ +│ │ (spiffe-helper bundled inside the AuthBridge sidecar above, │ │ +│ │ gated per-workload by SPIRE_ENABLED) │ │ │ └─────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────┘ + ▲ + │ Out-of-band: kagenti-operator's ClientRegistrationReconciler + │ creates the Keycloak client + a kagenti-keycloak-client-credentials + │ Secret. The webhook mounts that Secret into the AuthBridge sidecar + │ at /shared/client-{id,secret}.txt — no in-pod registration sidecar. │ │ Exchanged token (aud: target-service) ▼ @@ -94,22 +98,20 @@ AuthBridge solves the challenge of **secure service-to-service authentication** ```mermaid flowchart TB - subgraph WorkloadPod["WORKLOAD POD (with AuthBridge sidecars)"] - subgraph Init["Init Container"] + subgraph WorkloadPod["WORKLOAD POD (with AuthBridge sidecar)"] + subgraph Init["Init Container (envoy-sidecar mode only)"] ProxyInit["proxy-init
(iptables setup)"] end subgraph Containers["Containers"] App["Your Application"] - SpiffeHelper["SPIFFE Helper
(provides SVID)"] - ClientReg["client-registration
(registers with Keycloak)"] - subgraph Sidecar["AuthProxy Sidecar"] - AuthProxy["auth-proxy"] - Envoy["envoy-proxy"] - ExtProc["ext-proc"] - end + Sidecar["AuthBridge sidecar (combined image)
name = mode-dependent:
proxy-sidecar: authbridge-proxy
envoy-sidecar: envoy-proxy

(spiffe-helper bundled inside,
gated by SPIRE_ENABLED)"] end end + subgraph Operator["kagenti-operator (kagenti-system)"] + ClientReg["ClientRegistration
Reconciler"] + end + subgraph TargetPod["TARGET SERVICE POD"] Target["Target Service
(validates tokens)"] end @@ -121,21 +123,18 @@ flowchart TB Caller["Caller
(external)"] - SPIRE --> SpiffeHelper - SpiffeHelper --> ClientReg - ClientReg --> Keycloak + SPIRE --> Sidecar + ClientReg -->|"creates client + Secret"| Keycloak + ClientReg -.->|"Secret mounted at /shared/"| Sidecar Caller -->|"1. Get token"| Keycloak - Caller -->|"2. Pass token"| Envoy - Envoy -->|"3. Validate JWT (JWKS)"| ExtProc - ExtProc -->|"3a. Validation result"| Envoy - Envoy -->|"4. 401 if invalid"| Caller - Envoy -->|"5. Forward if valid"| App - App -->|"6. Request + Token"| Envoy - Envoy -->|"7. Token Exchange"| ExtProc - ExtProc -->|"8. Exchange with Keycloak"| Keycloak - Envoy -->|"9. Request + Exchanged Token"| Target - Target -->|"10. Response"| App - App -->|"11. Response"| Caller + Caller -->|"2. Pass token"| Sidecar + Sidecar -->|"3. Validate JWT (JWKS, returns 401 if invalid)"| Caller + Sidecar -->|"4. Forward if valid"| App + App -->|"5. Request + Token"| Sidecar + Sidecar -->|"6. Exchange via Keycloak"| Keycloak + Sidecar -->|"7. Request + Exchanged Token"| Target + Target -->|"8. Response"| App + App -->|"9. Response"| Caller style WorkloadPod fill:#e1f5fe style TargetPod fill:#e8f5e9 @@ -150,13 +149,19 @@ flowchart TB ### Workload Pod -| Component | Type | Purpose | -|-----------|------|---------| -| `proxy-init` | init | Sets up iptables to intercept inbound and outbound traffic (excludes Keycloak port) | -| `client-registration` | container | Registers workload with Keycloak using SPIFFE ID, saves credentials to `/shared/` | -| `spiffe-helper` | container | Provides SPIFFE credentials (SVID) | -| `Your App` | container | Your application; the demo uses a pass-through proxy as an example | -| `AuthProxy Sidecar` | container | Composed of Envoy + external processing (`Ext Proc`) components (shown as separate nodes in diagrams): validates inbound JWTs (signature + issuer via JWKS, returns 401 if invalid) and exchanges outbound tokens (HTTP: token exchange via Ext Proc; HTTPS: TLS passthrough) | +After kagenti-extensions#411 a workload pod has the application +container plus a single combined AuthBridge sidecar. In +envoy-sidecar mode it also has a one-shot `proxy-init` init +container; in proxy-sidecar mode (the cluster default) it does +not. `spiffe-helper` is bundled inside the sidecar image; client +registration runs in the operator, not the pod. + +| Component | Type | Mode | Purpose | +|-----------|------|------|---------| +| `proxy-init` | init | envoy-sidecar only | Sets up iptables to intercept inbound and outbound traffic (excludes Keycloak port to avoid token-exchange loops) | +| `Your App` | container | both | Your application | +| `authbridge-proxy` | container | proxy-sidecar (default) | Combined sidecar from the `authbridge` image: HTTP forward + reverse proxies, full plugin set (jwt-validation + token-exchange + a2a/mcp/inference parsers), bundled spiffe-helper gated by `SPIRE_ENABLED`. | +| `envoy-proxy` | container | envoy-sidecar | Combined sidecar from the `authbridge-envoy` image: Envoy + ext_proc + bundled spiffe-helper. Validates inbound JWTs (signature + issuer via JWKS) and exchanges outbound tokens; HTTPS is TLS-passthrough. | ### Target Service Pod @@ -380,10 +385,14 @@ This creates target clients, audience scopes, and assigns scopes to the agent. ## Component Documentation -- [Unified AuthBridge Binary](cmd/authbridge/README.md) - Single binary, three modes (recommended) -- [authlib](authlib/README.md) - Shared auth building blocks (Go library) -- [AuthProxy](authproxy/README.md) - Proxy-init, combined sidecar, demo app, and quickstart -- [Client Registration](client-registration/README.md) - Automatic Keycloak client registration with SPIFFE +- [authlib](authlib/README.md) — Shared auth building blocks (Go library) +- [cmd/authbridge-proxy](cmd/authbridge-proxy/) — proxy-sidecar binary (default mode, full plugin set) +- [cmd/authbridge-envoy](cmd/authbridge-envoy/) — envoy-sidecar binary (Envoy + ext_proc, full plugin set) +- [cmd/authbridge-lite](cmd/authbridge-lite/) — auth-only proxy-sidecar binary (no parsers) +- [proxy-init](proxy-init/README.md) — iptables init container (envoy-sidecar mode only) +- [docs/](docs/) — framework architecture and plugin author references + +Keycloak client registration is handled by the [kagenti-operator](https://github.com/kagenti/kagenti-operator)'s `ClientRegistrationReconciler`, not by an in-pod sidecar. ## References