带有 Secure Profile 的 Demo 只在 agent 上放置 AuthBridge —— tool 只是一个普通 workload,agent → tool 的跳转只是一次集群内原始 HTTP 调用。本指南是同一天气 demo 的 高级 版本:AuthBridge 在 两侧 都会注入,每次 agent-to-tool 调用都会经过 OAuth 2.0 RFC 8693 token exchange,因此 tool 看到的 token 具有以 tool 为受众范围的 audience。
具体来说:
- agent 的 AuthBridge 会将出站主机与
authproxy-routes 规则匹配,把其入站 JWT 交换为一个新的 access token,该 token 的 aud claim 是 tool 的 Keycloak client id,并在出站请求上注入 Authorization: Bearer <exchanged-token>。
- tool 的 AuthBridge 会在 MCP server 看到请求之前验证这个新 token。任何 token 中不包含 tool audience 的请求都会在 tool 的 ingress 处被拒绝——即使它来自 mesh 内的其他 workload。
- Keycloak 通过在两个 client 上都启用
standard.token.exchange.enabled: true,以及通过一个 client scope 和其中的 token mapper 将 tool 的 audience 写入交换后 token 的 aud claim 来完成该 exchange。
我们在每个资源上都使用 -advanced 命名后缀,这样该 demo 就可以与同一 team1 namespace 中的基础 secure-profile demo 共存。
涉及的 ConfigMap
高级 demo 会触及 demo namespace 中两个与 AuthBridge 相关的 ConfigMap:
authproxy-routes 是唯一需要你显式执行 kubectl apply 的部分。
Keycloak client-id 约定
operator 的 ClientRegistrationReconciler 会使用短格式 <namespace>/<workload> 为每个 workload 在 Keycloak 中注册 client(本 demo 中分别是 team1/weather-tool-advanced 和 team1/weather-service-advanced)。本指南中的每个 target_audience 以及 Keycloak client scope 上的每个 audience mapper 都采用这个短格式——这正是 tool 自己的入站 JWT-validation plugin 期望在交换后 token 的 aud claim 中看到的值。
前提条件
开始之前:
-
前两个指南都已完成(或者其步骤已在原地重做):
-
Kagenti operand 上的 featureGates.injectTools: true。 高级 demo 会在 tool 上注入 AuthBridge,而默认情况下 tool workload 的注入是关闭的。请先 patch 一次 operand,然后重启 controller 以让新 gate 生效:
kubectl -n kagenti-system patch kagenti kagenti --type merge \
-p '{"spec":{"featureGates":{"injectTools":true}}}'
kubectl -n kagenti-system rollout restart deploy/kagenti-controller-manager
-
demo namespace team1 已标记为 kagenti-enabled=true:
kubectl create namespace team1 --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace team1 kagenti-enabled=true --overwrite
-
一个 提供 OpenAI-compatible chat API 的 InferenceService(本指南在 models 中使用 qwen36-27b-gguf)。
-
具备访问集群的 kubectl 权限,并且可以读取 keycloak namespace 中的 keycloak-initial-admin。
本 demo 依赖的 client 在 Keycloak 中最终会使用如下名称——在整个指南中你会多次引用它们:
SPIFFE ID 这一列是 sidecar 通过 SPIRE 做 mTLS 时使用的——Alauda operator 不会把它当作 Keycloak 标识符。
与基础 demo 不同,这个 tool workload 会拥有自己的 AgentRuntime,因此 AuthBridge webhook 会注入 sidecar,operator 也会为 tool 的 ServiceAccount 注册一个 Keycloak client。请不要在 Deployment 上手动设置 kagenti.io/type: tool——ValidatingAdmissionPolicy 会把这个 label 保留给 operator,operator 会根据你创建的 AgentRuntime 来应用它。
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: weather-tool-advanced
namespace: team1
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: weather-tool-advanced
namespace: team1
labels:
app.kubernetes.io/name: weather-tool-advanced
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: weather-tool-advanced
template:
metadata:
labels:
app.kubernetes.io/name: weather-tool-advanced
spec:
serviceAccountName: weather-tool-advanced
containers:
- name: mcp
image: docker.io/alaudadockerhub/weather_tool:v0.1.0-rc.1
imagePullPolicy: IfNotPresent
env:
- name: PORT
value: "8000"
- name: HOST
value: 0.0.0.0
- name: UV_CACHE_DIR
value: /app/.cache/uv
- name: NO_PROXY
value: "127.0.0.1,localhost,.svc,.svc.cluster.local,geocoding-api.open-meteo.com,api.open-meteo.com"
ports:
- containerPort: 8000
volumeMounts:
- mountPath: /app/.cache
name: cache
volumes:
- name: cache
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: weather-tool-advanced-mcp
namespace: team1
spec:
selector:
app.kubernetes.io/name: weather-tool-advanced
ports:
- name: http
port: 8000
targetPort: 8000
---
apiVersion: agent.kagenti.dev/v1alpha1
kind: AgentRuntime
metadata:
name: weather-tool-advanced-runtime
namespace: team1
spec:
type: tool
targetRef:
apiVersion: apps/v1
kind: Deployment
name: weather-tool-advanced
EOF
serviceAccountName: weather-tool-advanced —— SPIRE 会签发一个以 spiffe://<trustDomain>/ns/team1/sa/weather-tool-advanced 为键的 X.509 SVID,operator 会将 Keycloak client id 注册为 team1/weather-tool-advanced。
NO_PROXY 覆盖了 tool 对公共地理编码 API 的出站调用。webhook 会尊重预先声明的 NO_PROXY,因此这些请求会绕过 tool 自己的 forward-proxy sidecar(参见 允许在 proxy-sidecar 模式下的出站流量)。
AgentRuntime 上的 spec.type: tool —— operator 会应用 kagenti.io/type: tool label,注入 AuthBridge sidecar(由 featureGates.injectTools: true 控制),并为 tool 注册 Keycloak client。
等待 sidecar 注入完成并且 credentials Secret 出现:
kubectl rollout status deploy/weather-tool-advanced -n team1 --timeout=180s
kubectl get pod -n team1 -l app.kubernetes.io/name=weather-tool-advanced
# NAME READY STATUS RESTARTS AGE
# weather-tool-advanced-XXXXXXXXXX-YYYYY 2/2 Running 0 1m
# The operator records the Secret name on the Deployment annotation
kubectl -n team1 get deploy weather-tool-advanced \
-o jsonpath='{.spec.template.metadata.annotations.kagenti\.io/keycloak-client-credentials-secret-name}'; echo
# kagenti-keycloak-client-credentials-<hash-tool>
READY 2/2 表示 tool 现在已经拥有一个 AuthBridge sidecar 和 mcp 应用容器。
第 2 步:为 token exchange 配置 Keycloak
operator 已经为 tool 创建了一个 Keycloak client(clientId 为 team1/weather-tool-advanced)。我们仍然需要进行三项 admin 更改:
- 创建一个 client scope
weather-tool-advanced-aud,并配置一个 audience mapper,把 team1/weather-tool-advanced 追加到交换后 token 的 aud claim 中。
- 在 tool client 上启用
standard.token.exchange.enabled: true。
- 将该 scope 作为 tool client 的一个 optional client scope,这样请求它的 token-exchange 才能成功。
我们通过一个临时 pod 来完成这三项操作;该 pod 会挂载平台的 keycloak-initial-admin Secret。请先把这个 Secret 复制到 demo namespace(因为 pod 只能挂载同 namespace 中的 Secret):
kubectl -n keycloak get secret keycloak-initial-admin -o yaml \
| sed 's/namespace: keycloak/namespace: team1/' \
| kubectl apply -f -
然后运行 setup Job:
kubectl apply -f - <<'EOF'
apiVersion: batch/v1
kind: Job
metadata:
name: keycloak-setup-advanced-tool
namespace: team1
spec:
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: OnFailure
containers:
- name: setup
image: docker.io/alaudadockerhub/curl:8.1.2
command: ["/bin/sh", "-c"]
args:
- |
set -eu
KC=http://keycloak-service.keycloak.svc:8080
USER=$(cat /kc/username)
PASS=$(cat /kc/password)
T=$(curl -s -X POST $KC/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli \
--data-urlencode "username=$USER" --data-urlencode "password=$PASS" \
| sed 's/.*"access_token":"\([^"]*\)".*/\1/')
AUTH="Authorization: Bearer $T"
echo "==> create client scope weather-tool-advanced-aud"
curl -sS -o /dev/null -w 'scope-status=%{http_code}\n' -X POST \
"$KC/admin/realms/kagenti/client-scopes" -H "$AUTH" -H 'Content-Type: application/json' -d '{
"name":"weather-tool-advanced-aud",
"protocol":"openid-connect",
"attributes":{"include.in.token.scope":"true","display.on.consent.screen":"true"},
"protocolMappers":[{
"name":"weather-tool-advanced-aud-mapper",
"protocol":"openid-connect",
"protocolMapper":"oidc-audience-mapper",
"consentRequired":false,
"config":{
"included.custom.audience":"team1/weather-tool-advanced",
"id.token.claim":"false",
"access.token.claim":"true",
"userinfo.token.claim":"false"
}
}]
}' || true
SCOPE_ID=$(curl -s "$KC/admin/realms/kagenti/client-scopes" -H "$AUTH" \
| tr '}' '\n' | grep 'weather-tool-advanced-aud' \
| grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')
echo "scope_id=$SCOPE_ID"
TOOL_ID=$(curl -s "$KC/admin/realms/kagenti/clients?clientId=team1%2Fweather-tool-advanced" -H "$AUTH" \
| grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')
echo "tool_id=$TOOL_ID"
echo "==> enable token exchange on tool client"
curl -sS -o /dev/null -w 'te-status=%{http_code}\n' -X PUT \
"$KC/admin/realms/kagenti/clients/$TOOL_ID" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"attributes":{"standard.token.exchange.enabled":"true"}}'
echo "==> add scope as optional on tool client"
curl -sS -o /dev/null -w 'opt-status=%{http_code}\n' -X PUT \
"$KC/admin/realms/kagenti/clients/$TOOL_ID/optional-client-scopes/$SCOPE_ID" -H "$AUTH"
volumeMounts:
- name: kc-admin
mountPath: /kc
readOnly: true
volumes:
- name: kc-admin
secret:
secretName: keycloak-initial-admin
EOF
kubectl -n team1 wait --for=condition=Complete job/keycloak-setup-advanced-tool --timeout=120s
kubectl -n team1 logs job/keycloak-setup-advanced-tool
日志末尾的预期输出:scope-status=201、te-status=204、opt-status=204。
第 3 步:准备 agent 的出站规则
agent 的 AuthBridge 会从同一 namespace 中的 authproxy-routes ConfigMap 读取出站规则。每一项都在说明:“当出站请求的 Host header 与此模式匹配时,将入站 JWT 交换为一个 aud claim 为该 audience 的 token,并请求这些 scope。”
请在部署 agent 之前先应用该规则,这样 sidecar 在首次启动时就能读取到它:
kubectl apply -f - <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: authproxy-routes
namespace: team1
data:
routes.yaml: |
- host: "weather-tool-advanced-mcp.team1.svc.cluster.local" # [!code callout]
target_audience: "team1/weather-tool-advanced" # [!code callout]
token_scopes: "openid weather-tool-advanced-aud"
EOF
host 会与 HTTP Host header 精确 匹配——不是短 service 名称。router 使用 gobwas/glob,并以 . 作为段分隔符,因此 * 不会跨越点。请使用完整的集群内 FQDN(不包含端口;router 在匹配前会去掉端口),或者显式列出你需要的模式。单独的 weather-tool-advanced-mcp 在这里并不匹配 weather-tool-advanced-mcp.team1.svc.cluster.local。
target_audience 是 tool 的 Keycloak clientId(Alauda 的 <ns>/<workload> 形式),也就是 tool 自身的入站 JWT-validation plugin 期望在交换后 token 的 aud 中看到的内容。
如果没有匹配的 route,agent 的 AuthBridge 会退回到 default_policy: passthrough——token 会被原样转发,其 aud claim 仍然是 team1/weather-service-advanced,而 tool 的 ingress 在严格配置下会拒绝它。
routes.yaml 在 Configure 时仅加载一次
token-exchange plugin 会在 sidecar 启动时读取 /etc/authproxy/routes.yaml。修改 ConfigMap 不会 立即影响已经运行的 pod,除非它们重启并且 kubelet 已将新内容同步到挂载中(约 60 秒)。当你更改 routes 时,请应用 ConfigMap,等待约 60 秒,然后对受影响的 workload 执行 kubectl delete pod。仅执行 rollout restart 可能会与 kubelet 的同步发生竞态,使新 pod 仍然读取旧文件。
第 4 步:部署受保护的 agent
与基础 secure demo 的结构相同,但使用 -advanced 命名,并指向高级 tool 的 Service。MCP_URL 和 route 的 host 字段必须一致——两者都必须是完整的集群内 FQDN。
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: weather-service-advanced
namespace: team1
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: weather-service-advanced
namespace: team1
labels:
app.kubernetes.io/name: weather-service-advanced
protocol.kagenti.io/a2a: ""
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: weather-service-advanced
template:
metadata:
labels:
app.kubernetes.io/name: weather-service-advanced
spec:
serviceAccountName: weather-service-advanced
containers:
- name: agent
image: docker.io/alaudadockerhub/weather_service:v0.1.0-rc.1
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
env:
- name: PORT
value: "8000"
- name: UV_CACHE_DIR
value: /app/.cache/uv
- name: MCP_URL
value: http://weather-tool-advanced-mcp.team1.svc.cluster.local:8000/mcp
- name: LLM_API_BASE
value: http://qwen36-27b-gguf-predictor.models.svc.cluster.local/v1
- name: LLM_API_KEY
value: dummy
- name: LLM_MODEL
value: qwen36-27b-gguf
---
apiVersion: v1
kind: Service
metadata:
name: weather-service-advanced
namespace: team1
spec:
selector:
app.kubernetes.io/name: weather-service-advanced
ports:
- name: http
port: 8000
targetPort: 8000
---
apiVersion: agent.kagenti.dev/v1alpha1
kind: AgentRuntime
metadata:
name: weather-service-advanced-runtime
namespace: team1
spec:
type: agent
targetRef:
apiVersion: apps/v1
kind: Deployment
name: weather-service-advanced
EOF
kubectl rollout status deploy/weather-service-advanced -n team1 --timeout=180s
kubectl get pods -n team1 -l app.kubernetes.io/name=weather-service-advanced
# NAME READY STATUS RESTARTS AGE
# weather-service-advanced-XXXXXXXXX-YYYYY 2/2 Running 0 1m
一旦 agent pod 就绪并且其 Keycloak client 已注册,请再运行一个第二个 setup Job,在 agent client 上启用 token exchange,并把同一个 scope 也作为 optional 分配给它。这个 Job 是第 2 步的两行变体:
kubectl apply -f - <<'EOF'
apiVersion: batch/v1
kind: Job
metadata:
name: keycloak-setup-advanced-agent
namespace: team1
spec:
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: OnFailure
containers:
- name: setup
image: docker.io/alaudadockerhub/curl:8.1.2
command: ["/bin/sh", "-c"]
args:
- |
set -eu
KC=http://keycloak-service.keycloak.svc:8080
USER=$(cat /kc/username)
PASS=$(cat /kc/password)
T=$(curl -s -X POST $KC/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli \
--data-urlencode "username=$USER" --data-urlencode "password=$PASS" \
| sed 's/.*"access_token":"\([^"]*\)".*/\1/')
AUTH="Authorization: Bearer $T"
SCOPE_ID=$(curl -s "$KC/admin/realms/kagenti/client-scopes" -H "$AUTH" \
| tr '}' '\n' | grep 'weather-tool-advanced-aud' \
| grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')
AGENT_ID=$(curl -s "$KC/admin/realms/kagenti/clients?clientId=team1%2Fweather-service-advanced" -H "$AUTH" \
| grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"\([^"]*\)"/\1/')
echo "agent_id=$AGENT_ID scope_id=$SCOPE_ID"
curl -sS -o /dev/null -w 'te-status=%{http_code}\n' -X PUT \
"$KC/admin/realms/kagenti/clients/$AGENT_ID" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"attributes":{"standard.token.exchange.enabled":"true"}}'
curl -sS -o /dev/null -w 'opt-status=%{http_code}\n' -X PUT \
"$KC/admin/realms/kagenti/clients/$AGENT_ID/optional-client-scopes/$SCOPE_ID" -H "$AUTH"
volumeMounts:
- name: kc-admin
mountPath: /kc
readOnly: true
volumes:
- name: kc-admin
secret:
secretName: keycloak-initial-admin
EOF
kubectl -n team1 wait --for=condition=Complete job/keycloak-setup-advanced-agent --timeout=120s
kubectl -n team1 logs job/keycloak-setup-advanced-agent
预期结果:te-status=204 和 opt-status=204 都会出现。
第 5 步:验证 token-exchange 路径
向 agent 发送一个已认证的 A2A message/send 请求,方式与 基础 secure demo 的第 4 步 完全相同。你挂载的 credentials Secret 是 agent 的——交换会在 agent 的 AuthBridge 内部透明完成。
从 Deployment 上的 operator annotation 中找到 agent 的 credentials Secret:
AGENT_SECRET=$(kubectl -n team1 get deploy weather-service-advanced \
-o jsonpath='{.spec.template.metadata.annotations.kagenti\.io/keycloak-client-credentials-secret-name}')
echo "$AGENT_SECRET"
# kagenti-keycloak-client-credentials-<agent-hash>
从 pod 中运行已认证查询(这里的 shell 使用上面得到的 $AGENT_SECRET 值):
kubectl apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: adv-smoke
namespace: team1
spec:
restartPolicy: Never
containers:
- name: query
image: docker.io/alaudadockerhub/curl:8.1.2
command: ["/bin/sh", "-c"]
args:
- |
set -e
CID=\$(cat /creds/client-id.txt)
CSEC=\$(cat /creds/client-secret.txt)
TOKEN=\$(curl -s \\
http://keycloak-service.keycloak.svc:8080/realms/kagenti/protocol/openid-connect/token \\
-d grant_type=client_credentials \\
--data-urlencode "client_id=\$CID" \\
--data-urlencode "client_secret=\$CSEC" \\
| sed 's/.*"access_token":"\\([^"]*\\)".*/\\1/')
curl -sS -X POST http://weather-service-advanced.team1.svc.cluster.local:8000/ \\
-H "Authorization: Bearer \$TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"jsonrpc":"2.0","id":"1","method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"What is the weather in Berlin?"}],"messageId":"m1"}}}'
volumeMounts:
- name: creds
mountPath: /creds
readOnly: true
volumes:
- name: creds
secret:
secretName: $AGENT_SECRET
EOF
kubectl -n team1 wait --for=condition=Ready pod/adv-smoke --timeout=60s
kubectl -n team1 logs adv-smoke
kubectl -n team1 delete pod adv-smoke
你应该会得到一个带有真实天气数据的已完成 A2A task。请求路径如下:
client ─(token A, aud=team1/weather-service-advanced)─▶ agent AuthBridge
│ validates token A (inbound)
▼
weather-service-advanced
│ MCP call to weather-tool-advanced-mcp
▼
agent AuthBridge outbound
│ matches route → RFC 8693 exchange
│ obtains token B (aud=team1/weather-tool-advanced)
▼
tool AuthBridge
│ validates token B against its own audience
▼
weather-tool-advanced (mcp)
│ HTTPS to geocoding-api.open-meteo.com (NO_PROXY)
▼
response
在 agent-sidecar 日志中确认 exchange 已发生:
kubectl -n team1 logs -c authbridge-proxy \
-l app.kubernetes.io/name=weather-service-advanced --tail=100 \
| grep -E 'outbound token exchanged|outbound passthrough'
# time=... level=INFO msg="outbound token exchanged" host=weather-tool-advanced-mcp.team1.svc.cluster.local:8000 audience=team1/weather-tool-advanced
再确认 tool 侧的入站验证:
kubectl -n team1 logs -c authbridge-proxy \
-l app.kubernetes.io/name=weather-tool-advanced --tail=100 \
| grep -E 'inbound authorized|inbound rejected'
# time=... level=INFO msg="inbound authorized" subject=<uuid> clientID=team1/weather-service-advanced
agent 侧的 "outbound token exchanged" 加上 tool 侧的 "inbound authorized",就是 RFC 8693 流程端到端接通的两行证明。
清理
kubectl delete namespace team1
如果你之后不再打算保护 tool workload,也可以把 Kagenti operand 上的 featureGates.injectTools 恢复为 false:
kubectl -n kagenti-system patch kagenti kagenti --type merge \
-p '{"spec":{"featureGates":{"injectTools":false}}}'
故障排查