A runnable demo of agent-to-agent auth where both agents are real, LLM-driven processes built with the Claude Agent SDK — not scripted stand-ins — talking to each other over HTTP, with Authorizer as the identity, delegation, and authorization layer underneath.
user ──chat──> assistant-agent (Claude) ──HTTP, Bearer <delegated token>──> infra-agent (Claude)
| |
| client_credentials + RFC 8693 token exchange | validates JWT (JWKS, aud)
v | check_permissions (OpenFGA)
Authorizer <─────────────────────────────────────────────────── |
:8080 admin/staging only
You chat with a DevOps assistant: "restart the payments service in staging",
then "restart the payments service in prod". The assistant is not allowed to
decide access on its own — it delegates to a separate infra agent process,
which is the one that actually checks whether you (not the assistant) are
allowed to act on that environment, via an OpenFGA can_deploy relation.
Staging works. Prod is denied — not because the code special-cases "prod", but
because setup.mjs only grants the demo user admin on environment:staging.
That's the point: authorization stays keyed to what the user can do, no
matter which agent is acting for them.
Other examples in this repo (with-agent-delegation, with-agents-python)
simulate multi-hop delegation with scripted fetch calls inside a single
script — great for seeing the RFC 8693 mechanics in isolation. Here, the two
agents are independent, long-running services, each with its own Claude
Agent SDK loop deciding what to do:
- assistant-agent.mjs — the front-line agent you chat with. Its Claude
loop decides when to call the
deploy_actiontool; the tool then does the OAuth/delegation dance and calls the infra agent over the network. - infra-agent.mjs — a resource server that also happens to use Claude: it enforces the permission check, then asks Claude to narrate the (simulated) execution plan. No real infrastructure is touched.
Requires Node 18+, a running Authorizer (make dev from the server repo root
→ :8080), and ANTHROPIC_API_KEY set (the Claude Agent SDK reads it, or use
ant auth login — see the SDK docs).
npm install
# One-time: installs the FGA model, creates the demo user + staging-only
# grant, and registers the assistant's service-account client.
node setup.mjs
# ^ prints `export ASSISTANT_CLIENT_ID=...` / `ASSISTANT_CLIENT_SECRET=...` — run those.
# Terminal 2:
npm run infra
# Terminal 1 (after exporting the two vars setup.mjs printed):
npm run assistantThen in the assistant's chat prompt:
You: restart payments in staging
You: restart payments in prod
The first succeeds (with a Claude-narrated "execution plan" from the infra agent). The second comes back denied — the assistant explains why, without retrying or working around it.
Every deploy_action tool call the assistant's Claude makes does this, fresh
each time (delegated tokens are 5 minutes, non-refreshable, by design):
client_credentials— the assistant authenticates as itself (its own registered service-account client) to get a machine token. This is the RFC 8693actor_token: proof of which agent is acting.- RFC 8693 token exchange — the assistant exchanges the user's own
session token (
subject_token) for a new token: samesub(the user), bound to the infra agent's URL (resource, RFC 8707 — so it's useless against any other API), scope narrowed toinfra:writeonly. - HTTP call to the infra agent — a real network hop between two processes, carrying that delegated bearer token.
- Local JWT validation — the infra agent checks signature (JWKS),
issuer, and
aud == its own resource URL, exactly like an MCP resource server would (seewith-mcp). check_permissions— the infra agent asks Authorizer, as itself (its own admin/service credential —ADMIN_SECRET), whether the token'ssub(the real user, extracted from the token it just validated locally) hascan_deployon that environment. It can't simply re-present the delegated token as its own bearer here: Authorizer's own API requiresaudto be Authorizer's own client_id, and this token'saudis deliberately this server (RFC 8707) — that mismatch is what makes the token useless anywhere but here. So the infra agent authenticates separately and passes an explicituser: "user:<sub>"override, which Authorizer honors only for a super-admin caller — never for the agent's own identity. Authorization stays keyed to the real human throughout (seeinternal/service/fga.goin the server repo), and an ambiguous case fails closed.- Only if allowed does the infra agent ask Claude to narrate an execution plan for the (simulated) action.
deploy_actionis the assistant's only tool — Claude decides when to call it from the conversation, it isn't scripted branching.- The assistant's chat loop is one
query()call per line (no cross-turn memory) — each DevOps command here is self-contained, so conversation state isn't needed for this demo. - Nothing here touches real infrastructure — the infra agent's response is
always
simulated: true. - Re-running
setup.mjsregisters a new assistant client each time; delete the old one from the dashboard (or via_delete_client) if you don't want it to accumulate.