From c8e84c7f6a0316bbd9a3510c4eb8d940409de3a3 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:52:40 +0100 Subject: [PATCH 1/7] examples: Keycloak realms for the agentgateway example The realm mesh has the agentmesh client with a groups claim and an audience mapper, so the access tokens an agent sends to agentgateway carry aud agentmesh; alice is in group devs and bob in group ops. The realm elsewhere is a second issuer the control plane does not trust. Tokens live 60 seconds, so the script can check expiry quickly. --- .../agentgateway/realm-elsewhere.json | 29 ++++++ .../examples/agentgateway/realm-mesh.json | 92 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 development/examples/agentgateway/realm-elsewhere.json create mode 100644 development/examples/agentgateway/realm-mesh.json 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 + } + ] + } + ] +} From 706d3707633f77814b468a9a7d74cdffd8b75129 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:52:40 +0100 Subject: [PATCH 2/7] examples: mesh policy and calc provider for the agentgateway example Nodes may publish mcp://*, and calc-user, granting mcp://calc, is bound to group:devs only. The provider node publishes the calculator of ../calc-mcp as mcp://calc. --- development/examples/agentgateway/node-provider.yaml | 9 +++++++++ development/examples/agentgateway/policy.json | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 development/examples/agentgateway/node-provider.yaml create mode 100644 development/examples/agentgateway/policy.json 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"] } + ] +} From 4397fafb25d0a8bdf0c0f152ac2c58d7f599e176 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:52:40 +0100 Subject: [PATCH 3/7] examples: agentgateway configuration exchanging each caller's token at a node jwtAuth (strict) validates the caller's Keycloak access token and removes it from the request. The backend's oauthTokenExchange sends the validated token (jwt.rawToken.unredacted()) to the node's /oauth/token as an RFC 8693 subject token, without client authentication, and puts the exchanged credential in X-Mesh-Authentication for the node's /mesh/ proxy. A second route applies the same policies in front of a header echo. --- .../examples/agentgateway/agentgateway.yaml | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 development/examples/agentgateway/agentgateway.yaml 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 From 3bef69773a414a0d14555932d7ddf090d2da4ba5 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:52:40 +0100 Subject: [PATCH 4/7] examples: compose the agentgateway example Keycloak, the control plane, one router, the calc provider, the shared node, agentgateway v1.6.0 and a server that answers with the headers it received. agentgateway starts once Keycloak is healthy, since it fetches the realm's keys at startup. The agentmesh images are pinned to the commit the example was tested with and can be overridden; tokens are read from files under .run/, which is ignored. --- development/examples/agentgateway/.gitignore | 1 + .../examples/agentgateway/compose.yaml | 126 ++++++++++++++++++ development/examples/agentgateway/echo.py | 21 +++ 3 files changed, 148 insertions(+) create mode 100644 development/examples/agentgateway/.gitignore create mode 100644 development/examples/agentgateway/compose.yaml create mode 100644 development/examples/agentgateway/echo.py 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/compose.yaml b/development/examples/agentgateway/compose.yaml new file mode 100644 index 00000000..4fd9305b --- /dev/null +++ b/development/examples/agentgateway/compose.yaml @@ -0,0 +1,126 @@ +# 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: + image: python:3.14-slim + 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() From 303b7830573a3a04de66d17fdc048351979332ac Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:48:17 +0100 Subject: [PATCH 5/7] examples: run the agentgateway cases and check each outcome run.sh brings the compose services up in order, then runs each case and prints whether it got the expected status, exiting non-zero on any mismatch: a call to mcp://calc through agentgateway as each user; no token, not a token, a token for another client and one from another realm, refused at agentgateway; the headers a backend receives; and one token through its expiry, including the minute after exp in which agentgateway still accepts it (the jsonwebtoken default leeway) and the control plane refuses the exchange. The admin, API and bootstrap tokens are generated at random into .run/ and handed to the components as files and to curl as header files, never as arguments. --- development/examples/agentgateway/run.sh | 226 +++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100755 development/examples/agentgateway/run.sh 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 From ea9950c107d1d679b177973d316ff7f2a1602d88 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 22:48:18 +0100 Subject: [PATCH 6/7] docs: guide for agentgateway in front of the mesh The guide walks through the agentgateway example: access tokens that carry the mesh's audience, validating the caller with jwtAuth, exchanging the token at the node's /oauth/token, the calls and what a backend receives, and what happens around the token's expiry. It lists where each refusal is logged and what to change for a real deployment, and is linked from the guides index. The example's README points to it. --- development/examples/agentgateway/README.md | 52 +++++ site/content/docs/guides/_index.md | 3 +- site/content/docs/guides/agentgateway.md | 204 ++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 development/examples/agentgateway/README.md create mode 100644 site/content/docs/guides/agentgateway.md 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/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/`. From 7d0834d66fd75ec58566f8c00612904574dd6d75 Mon Sep 17 00:00:00 2001 From: HosniBelfeki Date: Tue, 20 Oct 2026 23:06:23 +0100 Subject: [PATCH 7/7] examples: pin the agentgateway example's echo image by digest The header echo ran python:3.14-slim by tag. It now names the digest the repository's Python Dockerfiles already pin (calc-mcp, chat-a2a, greeter-mcp and the e2e images), so the example runs the image it was tested with. --- development/examples/agentgateway/compose.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/development/examples/agentgateway/compose.yaml b/development/examples/agentgateway/compose.yaml index 4fd9305b..bb0e3e03 100644 --- a/development/examples/agentgateway/compose.yaml +++ b/development/examples/agentgateway/compose.yaml @@ -112,7 +112,8 @@ services: # Answers with the request headers it received (echo.py), behind the same # agentgateway policies as the mesh route. echo: - image: python:3.14-slim + # 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