diff --git a/development/examples/agentgateway/.gitignore b/development/examples/agentgateway/.gitignore new file mode 100644 index 00000000..1b6128f1 --- /dev/null +++ b/development/examples/agentgateway/.gitignore @@ -0,0 +1 @@ +.run/ diff --git a/development/examples/agentgateway/README.md b/development/examples/agentgateway/README.md new file mode 100644 index 00000000..f9ea367f --- /dev/null +++ b/development/examples/agentgateway/README.md @@ -0,0 +1,52 @@ +# agentgateway in front of the mesh + +Everything the guide at `site/content/docs/guides/agentgateway.md` walks +through, as one `docker compose` project and a script that checks each step: + +- Keycloak 26.4.0 with the realm `mesh` (client `agentmesh`, an audience + mapper so access tokens carry `aud: agentmesh`, `alice` in group `devs`, + `bob` in group `ops`) and a second realm, `elsewhere`; +- agentgateway v1.6.0 ([`agentgateway.yaml`](agentgateway.yaml)), which + validates each caller's access token and exchanges it at a shared + `agentmesh-node` for a mesh credential of that caller; +- `agentmesh-control-plane` with the realm as its issuer, one router, the + calculator of [`../calc-mcp`](../calc-mcp) published as `mcp://calc`, and a + header echo (`echo.py`) behind the same agentgateway policies. + +```sh +./run.sh # bring it up, run every case, exit non-zero on a mismatch +./run.sh down # remove the containers and the generated tokens +``` + +`run.sh` prints one line per case: + +``` +== Calling mcp://calc through agentgateway + PASS alice (calc-user through group:devs) 200 + PASS bob (no calc-user), refused by the provider 403 + +== Refused at agentgateway + PASS no token 401 + PASS not a token 401 + PASS alice, token for client other-app (audience) 401 + PASS alice, token from realm elsewhere (issuer) 401 + +== What a backend behind agentgateway receives + PASS the exchanged credential in x-mesh-authentication present + PASS the caller's token in authorization absent + +== A token through its expiry (60s lifetime in this realm) + PASS alice, before exp 200 + PASS alice, 20s after exp: accepted by agentgateway, refused at the exchange 400 + PASS alice, 80s after exp: refused by agentgateway 401 + PASS alice, a new token 200 +``` + +The agentmesh images are pinned to the commit the example was tested with. +`AGENTMESH_TAG` selects another published commit or release, and +`AGENTMESH_REGISTRY` another registry. + +`.run/` holds the admin, API and bootstrap tokens `run.sh` generates on each +run, and is not committed. The users' passwords in the realm files are for +this example only. Keycloak runs `start-dev` over plain `http` inside the +Compose network; the guide lists what to change for a real deployment. diff --git a/development/examples/agentgateway/agentgateway.yaml b/development/examples/agentgateway/agentgateway.yaml new file mode 100644 index 00000000..33d5d63e --- /dev/null +++ b/development/examples/agentgateway/agentgateway.yaml @@ -0,0 +1,53 @@ +# agentgateway in front of a shared agentmesh-node. +# +# jwtAuth validates each caller's Keycloak access token and removes it from +# the request. The token exchange reads the validated token instead, trades +# it at the node's /oauth/token (RFC 8693) for a credential of that caller, +# and sends that credential to the backend in X-Mesh-Authentication. The +# node needs no client authentication for a JWT subject token, so the +# gateway holds no secret and no credential of its own. +# +# /mesh/ goes to the node's mesh proxy. /echo/ applies the same policies in +# front of a server that answers with the headers it received, to show what +# a backend gets. +config: {} +binds: +- port: 3000 + listeners: + - name: default + protocol: HTTP + routes: + - name: mesh + matches: + - path: + pathPrefix: /mesh/ + policies: &caller-policies + jwtAuth: + mode: strict + issuer: http://keycloak:8080/realms/mesh + audiences: [agentmesh] + jwks: + url: http://keycloak:8080/realms/mesh/protocol/openid-connect/certs + backends: + - host: gateway:8080 + policies: &exchange + backendAuth: + oauthTokenExchange: + host: gateway:8080 + path: /oauth/token + subjectToken: + tokenType: urn:ietf:params:oauth:token-type:jwt + source: + expression: jwt.rawToken.unredacted() + authorizationLocation: + header: + name: x-mesh-authentication + prefix: "Bearer " + - name: echo + matches: + - path: + pathPrefix: /echo/ + policies: *caller-policies + backends: + - host: echo:8080 + policies: *exchange diff --git a/development/examples/agentgateway/compose.yaml b/development/examples/agentgateway/compose.yaml new file mode 100644 index 00000000..bb0e3e03 --- /dev/null +++ b/development/examples/agentgateway/compose.yaml @@ -0,0 +1,127 @@ +# agentgateway in front of an Agent Mesh: two users in two Keycloak groups +# call one MCP service through agentgateway, which exchanges each caller's +# token for a mesh credential of that caller at a shared agentmesh-node. +# run.sh brings this up in order and runs the cases; see README.md. +# +# The agentmesh images are pinned to the commit the example was tested +# with. AGENTMESH_TAG selects another published commit or release, and +# AGENTMESH_REGISTRY another registry (for images built from this checkout). +name: agentmesh-agentgateway + +x-agentmesh-node: &agentmesh-node + image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-node:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72} + depends_on: + - router + volumes: + - ./.run:/run/example:ro + - ./node-provider.yaml:/etc/agentmesh/provider.yaml:ro + +services: + keycloak: + image: quay.io/keycloak/keycloak:26.4.0 + # start-dev serves plain http and keeps its data in the container: for + # this example only. Production Keycloak needs TLS, a database and a + # fixed hostname (https://www.keycloak.org/server/configuration-production). + command: ["start-dev", "--import-realm"] + environment: + KC_HEALTH_ENABLED: "true" + volumes: + - ./realm-mesh.json:/opt/keycloak/data/import/realm-mesh.json:ro + - ./realm-elsewhere.json:/opt/keycloak/data/import/realm-elsewhere.json:ro + healthcheck: + # The image has no HTTP client: ask the management port with bash and + # read the status line, so a 503 while the realms import is a failure. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *' 200 '* ]]"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + + control-plane: + image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-control-plane:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72} + command: + - --issuer=http://keycloak:8080/realms/mesh + - --allowed-audiences=agentmesh + - --admin-token-path=/run/example/admin.token + - --db-dsn=/data/control-plane.db + # The router and the two nodes enroll with single-use bootstrap tokens + # run.sh mints with the admin token; approving them as they arrive + # spares the script an approval step. + - --auto-approve-enrollment + volumes: + - ./.run:/run/example:ro + depends_on: + keycloak: + condition: service_healthy + + router: + image: ${AGENTMESH_REGISTRY:-ghcr.io/google}/agentmesh-router:${AGENTMESH_TAG:-8de8bd76ddfa5c52957c02722b6bce2eac21ec72} + command: + - --control-plane=http://control-plane:8080 + - --insecure-control-plane + - --bootstrap-token-path=/run/example/router.token + - --keys-path=/data/router.key + - --control-plane-sync-interval=10s + volumes: + - ./.run:/run/example:ro + depends_on: + - control-plane + + calc: + build: ../calc-mcp + image: agentmesh-agentgateway/calc-mcp:local + + # Publishes mcp://calc (node-provider.yaml). + provider: + <<: *agentmesh-node + command: + - run + - --control-plane=http://control-plane:8080 + - --insecure-control-plane + - --bootstrap-token-path=/run/example/provider.token + - --config=/etc/agentmesh/provider.yaml + - --data-dir=/data + - --api-token-path=/run/example/provider-api.token + - --discovery-interval=1s + - --control-plane-sync-interval=10s + + # The node agentgateway exchanges tokens at and sends requests to. Its own + # credential grants no service. + gateway: + <<: *agentmesh-node + command: + - run + - --control-plane=http://control-plane:8080 + - --insecure-control-plane + - --bootstrap-token-path=/run/example/gateway.token + - --data-dir=/data + - --bind-addr=0.0.0.0:8080 + - --api-token-path=/run/example/gateway-api.token + - --control-plane-sync-interval=10s + + agentgateway: + image: ghcr.io/agentgateway/agentgateway:v1.6.0 + command: ["-f", "/etc/agentgateway/config.yaml"] + volumes: + - ./agentgateway.yaml:/etc/agentgateway/config.yaml:ro + depends_on: + # agentgateway fetches the realm's keys when it starts. + keycloak: + condition: service_healthy + + # Answers with the request headers it received (echo.py), behind the same + # agentgateway policies as the mesh route. + echo: + # The same image, by digest, as the repository's Python Dockerfiles. + image: python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 + command: ["python3", "/echo.py"] + volumes: + - ./echo.py:/echo.py:ro + + # The HTTP client run.sh uses inside the network, so token issuers and + # service names resolve exactly as the components see them. + tools: + image: curlimages/curl:8.11.1 + profiles: ["cases"] + volumes: + - ./:/example:ro diff --git a/development/examples/agentgateway/echo.py b/development/examples/agentgateway/echo.py new file mode 100644 index 00000000..7eb08d07 --- /dev/null +++ b/development/examples/agentgateway/echo.py @@ -0,0 +1,21 @@ +# Answers every POST with the request headers it received, values shortened, +# so run.sh can show what agentgateway forwards to a backend. +import http.server +import json + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get("content-length", 0))) + headers = {k.lower(): v if len(v) <= 24 else v[:24] + "..." for k, v in self.headers.items()} + body = json.dumps(headers, sort_keys=True).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +http.server.HTTPServer(("0.0.0.0", 8080), Handler).serve_forever() diff --git a/development/examples/agentgateway/node-provider.yaml b/development/examples/agentgateway/node-provider.yaml new file mode 100644 index 00000000..9acaf6fc --- /dev/null +++ b/development/examples/agentgateway/node-provider.yaml @@ -0,0 +1,9 @@ +# The node that publishes mcp://calc. Its credential comes from a bootstrap +# token, and the mesh policy grants mesh:role:node allowed_publish mcp://*. +version: "v1alpha1" + +services: + - type: mcp + name: calc + description: "add and multiply" + target_url: "http://calc:7777/mcp" diff --git a/development/examples/agentgateway/policy.json b/development/examples/agentgateway/policy.json new file mode 100644 index 00000000..e058b9e5 --- /dev/null +++ b/development/examples/agentgateway/policy.json @@ -0,0 +1,9 @@ +{ + "roles": [ + { "name": "mesh:role:node", "allowed_targets": ["*"], "allowed_publish": ["mcp://*"] }, + { "name": "calc-user", "allowed_services": ["mcp://calc"], "allowed_targets": ["*"] } + ], + "bindings": [ + { "role": "calc-user", "members": ["group:devs"] } + ] +} diff --git a/development/examples/agentgateway/realm-elsewhere.json b/development/examples/agentgateway/realm-elsewhere.json new file mode 100644 index 00000000..5e621b1f --- /dev/null +++ b/development/examples/agentgateway/realm-elsewhere.json @@ -0,0 +1,29 @@ +{ + "realm": "elsewhere", + "enabled": true, + "accessTokenLifespan": 60, + "clients": [ + { + "clientId": "agentmesh", + "publicClient": true, + "directAccessGrantsEnabled": true + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "email": "alice@example.test", + "emailVerified": true, + "firstName": "Alice", + "lastName": "Elsewhere", + "credentials": [ + { + "type": "password", + "value": "alice-test-only", + "temporary": false + } + ] + } + ] +} diff --git a/development/examples/agentgateway/realm-mesh.json b/development/examples/agentgateway/realm-mesh.json new file mode 100644 index 00000000..f7e97bea --- /dev/null +++ b/development/examples/agentgateway/realm-mesh.json @@ -0,0 +1,92 @@ +{ + "realm": "mesh", + "enabled": true, + "accessTokenLifespan": 60, + "groups": [ + { + "name": "devs" + }, + { + "name": "ops" + } + ], + "clients": [ + { + "clientId": "agentmesh", + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ + "http://127.0.0.1/*", + "http://localhost/*" + ], + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "config": { + "claim.name": "groups", + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "agentmesh", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + }, + { + "clientId": "other-app", + "publicClient": true, + "directAccessGrantsEnabled": true + } + ], + "users": [ + { + "username": "alice", + "enabled": true, + "email": "alice@example.test", + "emailVerified": true, + "firstName": "Alice", + "lastName": "Dev", + "groups": [ + "/devs" + ], + "credentials": [ + { + "type": "password", + "value": "alice-test-only", + "temporary": false + } + ] + }, + { + "username": "bob", + "enabled": true, + "email": "bob@example.test", + "emailVerified": true, + "firstName": "Bob", + "lastName": "Ops", + "groups": [ + "/ops" + ], + "credentials": [ + { + "type": "password", + "value": "bob-test-only", + "temporary": false + } + ] + } + ] +} diff --git a/development/examples/agentgateway/run.sh b/development/examples/agentgateway/run.sh new file mode 100755 index 00000000..255009c3 --- /dev/null +++ b/development/examples/agentgateway/run.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# agentgateway in front of an Agent Mesh. Brings compose.yaml up in order, +# then runs each case of README.md and checks its outcome. +# +# ./run.sh bring everything up and run every case +# ./run.sh down stop everything and delete the generated tokens + +set -euo pipefail +# The components run as a non-root user and read the files written below. +umask 022 +# Git Bash on Windows rewrites arguments that look like paths; the container +# paths below are meant literally. +export MSYS_NO_PATHCONV=1 + +cd "$(dirname "${BASH_SOURCE[0]}")" +RUN_DIR=.run +COMPOSE=(docker compose --progress=quiet) + +if [[ "${1:-}" == "down" ]]; then + "${COMPOSE[@]}" --profile cases down --volumes --remove-orphans + rm -rf "${RUN_DIR}" + exit 0 +fi + +failures=0 + +# expect NAME WANT GOT prints one result line and counts a mismatch. +expect() { + if [[ "$3" == "$2" ]]; then + printf ' PASS %-66s %s\n' "$1" "$3" + else + printf ' FAIL %-66s got %s, want %s\n' "$1" "$3" "$2" + failures=$((failures + 1)) + fi +} + +# http runs curl inside the compose network, where keycloak, control-plane +# and agentgateway resolve as the components themselves see them. +http() { + "${COMPOSE[@]}" run --rm --no-deps -T tools -s "$@" +} + +# admin calls the control plane's admin API with the generated admin token. +admin() { + local method=$1 path=$2 + shift 2 + http -X "${method}" -H "@/example/${RUN_DIR}/admin.header" "$@" "http://control-plane:8080${path}" +} + +random_token() { + head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n' +} + +# mint ROLE NAME writes a single-use bootstrap token for ROLE to .run/NAME.token. +mint() { + admin POST /admin/bootstrap-tokens -H 'Content-Type: application/json' \ + -d "{\"role\":\"$1\",\"ttl_hours\":1,\"max_usages\":1,\"description\":\"agentgateway example $2\"}" | + sed -n 's/.*"token": *"\([^"]*\)".*/\1/p' >"${RUN_DIR}/$2.token" + [[ -s "${RUN_DIR}/$2.token" ]] || { echo "could not mint a bootstrap token for $2" >&2; exit 1; } +} + +# access_token USER CLIENT REALM logs USER in with the password grant and +# writes "Authorization: Bearer " to .run/USER.CLIENT.REALM.header. +# The password grant only lets this script run unattended: an agent or MCP +# client gets its token from its own login flow. +access_token() { + local base="${RUN_DIR}/$1.$2.$3" token + token=$(http -d grant_type=password -d "client_id=$2" -d "username=$1" -d "password=$1-test-only" -d scope=openid \ + "http://keycloak:8080/realms/$3/protocol/openid-connect/token" | + sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') + [[ -n "${token}" ]] || { echo "no access token for $1 from realm $3, client $2" >&2; exit 1; } + printf 'Authorization: Bearer %s\n' "${token}" >"${base}.header" +} + +# wait_log SERVICE TEXT waits for TEXT in the logs of SERVICE. +wait_log() { + for _ in $(seq 1 90); do + if "${COMPOSE[@]}" logs "$1" 2>/dev/null | grep -qF "$2"; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for \"$2\" from $1" >&2 + "${COMPOSE[@]}" logs --tail 20 "$1" >&2 + exit 1 +} + +# wait_until EPOCH sleeps until the given Unix time, if it is still ahead. +wait_until() { + local left=$(($1 - $(date +%s))) + if ((left > 0)); then + echo " waiting ${left}s" + sleep "${left}" + fi +} + +INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"agentgateway-example","version":"0"}}}' + +# call_calc [HEADER] sends an MCP initialize to mcp://calc through +# agentgateway, with the header file .run/HEADER if given, and prints the +# HTTP status. +call_calc() { + local auth=() + if [[ -n "${1:-}" ]]; then + auth=(-H "@/example/${RUN_DIR}/$1") + fi + http -o /dev/null -w '%{http_code}' -X POST "${auth[@]}" \ + -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ + --data "${INIT}" "http://agentgateway:3000/mesh/${PROVIDER}/mcp/calc" +} + +echo "== Generating test-only tokens in ${RUN_DIR}/" +mkdir -p "${RUN_DIR}" +random_token >"${RUN_DIR}/admin.token" +printf 'Authorization: Bearer %s\n' "$(cat "${RUN_DIR}/admin.token")" >"${RUN_DIR}/admin.header" +for name in provider-api gateway-api; do + random_token >"${RUN_DIR}/${name}.token" +done +printf 'Authorization: Bearer not-a-token\n' >"${RUN_DIR}/not-a-token.header" + +echo "== Starting Keycloak, the control plane, the calc MCP server and the header echo" +"${COMPOSE[@]}" up -d --build keycloak control-plane calc echo +control_plane_ready=false +for _ in $(seq 1 60); do + if [[ "$(http -o /dev/null -w '%{http_code}' http://control-plane:8080/info || true)" == 200 ]]; then + control_plane_ready=true + break + fi + sleep 2 +done +if [[ "${control_plane_ready}" == false ]]; then + echo "the control plane did not answer /info within 120s" >&2 + "${COMPOSE[@]}" logs --tail 20 control-plane >&2 + exit 1 +fi + +echo "== Loading the mesh policy (calc-user bound to group:devs)" +expect "load policy.json" 200 "$(admin POST /policies -H 'Content-Type: application/json' \ + --data-binary @/example/policy.json -o /dev/null -w '%{http_code}')" + +echo "== Starting the router, the calc provider, the shared node and agentgateway" +mint mesh:role:router router +mint mesh:role:node provider +mint mesh:role:node gateway +"${COMPOSE[@]}" up -d router provider gateway +wait_log provider 'Registered and announced SERVICE_TYPE_MCP/calc' +wait_log gateway 'Agent Mesh Node Online' +"${COMPOSE[@]}" up -d agentgateway +wait_log agentgateway 'started bind' +PROVIDER=$("${COMPOSE[@]}" logs provider | sed -n 's/.*PeerID: \(12D3KooW[1-9A-HJ-NP-Za-km-z]*\).*/\1/p' | head -n 1) +[[ -n "${PROVIDER}" ]] || { echo "could not read the provider's peer ID" >&2; exit 1; } + +echo "== Tokens from Keycloak" +access_token alice agentmesh mesh +access_token bob agentmesh mesh +access_token alice other-app mesh +access_token alice agentmesh elsewhere + +echo +echo "== Calling mcp://calc through agentgateway" +expect "alice (calc-user through group:devs)" 200 "$(call_calc alice.agentmesh.mesh.header)" +expect "bob (no calc-user), refused by the provider" 403 "$(call_calc bob.agentmesh.mesh.header)" + +echo +echo "== Refused at agentgateway" +expect "no token" 401 "$(call_calc)" +expect "not a token" 401 "$(call_calc not-a-token.header)" +expect "alice, token for client other-app (audience)" 401 "$(call_calc alice.other-app.mesh.header)" +expect "alice, token from realm elsewhere (issuer)" 401 "$(call_calc alice.agentmesh.elsewhere.header)" + +echo +echo "== What a backend behind agentgateway receives" +received=$(http -X POST -H "@/example/${RUN_DIR}/alice.agentmesh.mesh.header" --data '{}' http://agentgateway:3000/echo/headers) +echo " ${received}" +expect "the exchanged credential in x-mesh-authentication" present \ + "$(grep -q '"x-mesh-authentication": "Bearer ' <<<"${received}" && echo present || echo absent)" +expect "the caller's token in authorization" absent \ + "$(grep -q '"authorization"' <<<"${received}" && echo present || echo absent)" + +echo +echo "== A token through its expiry (60s lifetime in this realm)" +access_token alice agentmesh mesh +issued_at=$(date +%s) +cp "${RUN_DIR}/alice.agentmesh.mesh.header" "${RUN_DIR}/alice-expiring.header" +expect "alice, before exp" 200 "$(call_calc alice-expiring.header)" +# agentgateway accepts a token for 60 seconds after its exp (the default +# leeway of the jsonwebtoken library, with no setting in v1.6.0); the control +# plane refuses it at the exchange, and agentgateway answers 400. +wait_until $((issued_at + 80)) +expect "alice, 20s after exp: accepted by agentgateway, refused at the exchange" 400 "$(call_calc alice-expiring.header)" +wait_until $((issued_at + 140)) +expect "alice, 80s after exp: refused by agentgateway" 401 "$(call_calc alice-expiring.header)" +access_token alice agentmesh mesh +expect "alice, a new token" 200 "$(call_calc alice.agentmesh.mesh.header)" + +echo +echo "== Where each refusal is logged" +echo "-- agentgateway: tokens it refused" +"${COMPOSE[@]}" logs --no-log-prefix agentgateway | + sed -n 's/.*error="\(authentication failure: .*\)" reason=.*/\1/p' | sort | uniq -c || true +echo "-- control plane: exchanges it refused" +"${COMPOSE[@]}" logs --no-log-prefix control-plane | grep -F 'Border Crossing' | grep -F '"decision": "deny"' | tail -n 2 || true +echo "-- provider: calls the caller's credential does not grant" +"${COMPOSE[@]}" logs --no-log-prefix provider | grep -F 'AuthZ Denied' | tail -n 2 || true + +echo +if [[ "${failures}" -eq 0 ]]; then + echo "All cases passed. Everything is still running; ./run.sh down removes it." +else + echo "${failures} case(s) failed. Everything is still running for inspection; ./run.sh down removes it." + exit 1 +fi diff --git a/site/content/docs/guides/_index.md b/site/content/docs/guides/_index.md index afed5ca6..e518d5b2 100644 --- a/site/content/docs/guides/_index.md +++ b/site/content/docs/guides/_index.md @@ -9,7 +9,8 @@ aliases: Step-by-step pages for common tasks: publish a service, connect an agent client, enroll machines that cannot log in, use Keycloak as the identity -provider, put an agent in an OpenShell sandbox or a Substrate actor on the +provider, put agentgateway in front of the mesh, put an agent in an +OpenShell sandbox or a Substrate actor on the mesh, and deploy a control plane on Kubernetes, Cloud Run, SkyPilot or a GitHub codespace. Each page assumes that you have read the [quick start](../getting-started/quickstart/). diff --git a/site/content/docs/guides/agentgateway.md b/site/content/docs/guides/agentgateway.md new file mode 100644 index 00000000..cb4354ef --- /dev/null +++ b/site/content/docs/guides/agentgateway.md @@ -0,0 +1,204 @@ +--- +title: "agentgateway in front of the mesh" +linkTitle: "agentgateway" +weight: 3 +--- + +This guide puts [agentgateway](https://agentgateway.dev/) in front of the +mesh, so a gateway that already authenticates your users passes each of them +on with a credential of their own. agentgateway validates the caller's token +from your identity provider, exchanges it at an `agentmesh-node` for a mesh +credential of that caller, and calls the mesh with it. The call never becomes +the gateway's own identity: agentgateway holds no mesh credential, and the +caller's own token does not travel past it. + +The whole setup runs on one machine with Docker: +[`development/examples/agentgateway/`](https://github.com/google/agentmesh/tree/main/development/examples/agentgateway) +in the repository has the Compose project, and its `run.sh` runs every step +below and checks each result. It uses Keycloak as the identity provider, as +in [Keycloak as the identity provider](../keycloak/). + +## How the pieces fit + +```mermaid +flowchart LR + Client["caller
Keycloak access token"] + subgraph Docker["one Compose network"] + AGW["agentgateway
jwtAuth, token exchange"] + KC["Keycloak
realm mesh"] + GW["agentmesh-node
/oauth/token, /mesh/"] + CP["agentmesh-control-plane"] + R["router"] + P["agentmesh-node
mcp://calc"] + end + Client -- "Authorization: Bearer" --> AGW + AGW -. "JWKS" .-> KC + AGW -- "POST /oauth/token" --> GW + GW -- "POST /token/exchange" --> CP + AGW -- "X-Mesh-Authentication" --> GW + GW --> R --> P +``` + +agentgateway sends the caller's token to the node's +[token endpoint](../../reference/node-api/#token-endpoint-oauthtoken-and-revocation-oauthrevoke), +which exchanges it at the control plane for a credential bound to that +caller. agentgateway then calls the node's `/mesh/` proxy with that +credential, and the node that publishes the service checks it against the +mesh policy. + +## Before you start + +You need Docker with Compose v2 and bash (Linux, macOS, or Git Bash on +Windows). + +```sh +cd development/examples/agentgateway +./run.sh +``` + +## 1. Keycloak: access tokens for the mesh + +An agent or MCP client sends agentgateway an access token. An access token +from this realm carries no `aud` claim unless a mapper adds one, so the +realm's `agentmesh` client has an audience mapper: + +```json +{ + "name": "audience", + "protocolMapper": "oidc-audience-mapper", + "config": { "included.client.audience": "agentmesh", "access.token.claim": "true" } +} +``` + +`alice`'s access token then carries `"aud": "agentmesh"` and +`"groups": ["devs"]`, which is what the control plane checks +(`--allowed-audiences agentmesh`) and what the policy binds +(`calc-user` to `group:devs`). + +## 2. agentgateway: validate the caller + +The route validates the token before anything else, so a request without a +valid token is refused with `401`: + +```yaml +policies: + jwtAuth: + mode: strict + issuer: http://keycloak:8080/realms/mesh + audiences: [agentmesh] + jwks: + url: http://keycloak:8080/realms/mesh/protocol/openid-connect/certs +``` + +After it validates the token, agentgateway removes it from the request, so no +backend receives it. + +## 3. agentgateway: exchange the token at the node + +The backend's `oauthTokenExchange` sends the validated token to the node's +`/oauth/token` as an RFC 8693 subject token, and places the credential it gets +back in `X-Mesh-Authentication`, the header the node's `/mesh/` proxy reads: + +```yaml +backends: +- host: gateway:8080 + policies: + backendAuth: + oauthTokenExchange: + host: gateway:8080 + path: /oauth/token + subjectToken: + tokenType: urn:ietf:params:oauth:token-type:jwt + source: + expression: jwt.rawToken.unredacted() + authorizationLocation: + header: + name: x-mesh-authentication + prefix: "Bearer " +``` + +- `subjectToken.source` reads the token from the result of `jwtAuth`, because + `jwtAuth` has already removed it from the `Authorization` header. +- There is no `clientAuth`. The node exchanges a JWT subject token without + client authentication: the control plane verifies the token's issuer and + audience, and mints a credential bound to that node that carries the + caller's identity and roles. agentgateway has no identity of its own in + it. + +## 4. Call a service + +```sh +curl -X POST http://agentgateway:3000/mesh//mcp/calc \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"example","version":"0"}}}' +``` + +| Caller | Result | +|---|---| +| `alice`, group `devs` | `200`, the server's `initialize` response | +| `bob`, group `ops` | `403` from the node that publishes `mcp://calc` | +| No token, or not a token | `401` at agentgateway | +| A token for another client | `401` at agentgateway: it has no `aud` claim | +| A token from another realm | `401` at agentgateway: its signing key is not in the realm's JWKS | + +## 5. What a backend receives + +The example puts a server that answers with the headers it received behind +the same agentgateway policies. For `alice`'s request it gets: + +```json +{"host": "echo", "x-mesh-authentication": "Bearer EsUDCtoCCgpleHBpc...", ...} +``` + +The exchanged mesh credential is there, and there is no `Authorization` +header: `alice`'s Keycloak token went no further than agentgateway. + +## 6. When the caller's token expires + +| Moment | Result | +|---|---| +| Before `exp` | `200` | +| Up to 60 seconds after `exp` | `400 invalid request` | +| More than 60 seconds after `exp` | `401` at agentgateway | +| A new token | `200` | + +agentgateway accepts a token for 60 seconds after its `exp`: that is the +default leeway of the `jsonwebtoken` library it validates tokens with, and +agentgateway v1.6.0 has no setting for it. The control plane allows none, so +in that minute it refuses the exchange and agentgateway answers `400`. A +client that refreshes its token before `exp` never sees this; one that waits +for a `401` to refresh waits up to a minute longer. + +## Where each refusal is logged + +| Refusal | Logged by | Line | +|---|---|---| +| A missing, malformed, expired or foreign token | agentgateway | `authentication failure: ...` with `reason=JwtAuth` | +| An exchange the control plane refuses | control plane | `Border Crossing` with `"decision": "deny"` | +| A call the caller's credential does not grant | the node that publishes the service | `[Ingress] AuthZ Denied` with the failed check | + +## What to change for a real deployment + +- Run Keycloak for production: TLS, a database and a fixed hostname + ([Configuring Keycloak for production](https://www.keycloak.org/server/configuration-production)). + Serve the control plane over `https` and drop `--insecure-control-plane` + from the router and the nodes. +- Put agentgateway and the node it exchanges at on the same trusted host or + network, and give the token exchange `policies.backendTLS` when the node is + served over TLS. +- The example gets tokens with the password grant so it runs unattended. Your + agents and MCP clients get theirs from their own login flow. +- The example's control plane runs with `--auto-approve-enrollment` so the + router and the two nodes enroll with the bootstrap tokens the script mints. + Without it, an administrator approves each bootstrap enrollment + ([Headless enrollment](../headless-enrollment/#3-approve)). + +## Cleaning up + +```sh +./run.sh down +``` + +This stops every container and deletes the tokens the script generated +under `.run/`.