Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ scripts/smoke.sh web/express-openid my-example

It uses throwaway credentials and needs no Vouch account. Probes are chosen per category: web apps must render `/`; static SPAs must render `/` **and** have their `__VOUCH_*` placeholders substituted into the built bundle (`entrypoint.sh` exits 0 even when its `sed` glob matches nothing, so this is the only thing catching a bundler output-layout change); MCP and A2A servers must serve their well-known metadata and reject unauthenticated calls with 401; native CLIs must get past module loading.

A Playwright end-to-end suite lives in `tests/` (specs for web, spa, native, mcp, a2a) and is driven by the root `Makefile` (`make test`, `make test-mcp`, …). It is **not** wired into CI: it shells out to the macOS Keychain for a DPoP signing key, needs a live hardware-key-backed Vouch session (`~/.vouch/cookie.txt`), and creates/destroys real OAuth applications. Run it locally before merging anything non-trivial.
A Playwright end-to-end suite lives in `tests/` (specs for web, spa, native, mcp, a2a, claims) and is driven by the root `Makefile` (`make test`, `make test-mcp`, …). It is **not** wired into CI: it shells out to the macOS Keychain for a DPoP signing key, needs a live hardware-key-backed Vouch session, and creates/destroys real OAuth applications. Run it locally before merging anything non-trivial.

Credentials come from the Vouch CLI's XDG locations — `$XDG_CONFIG_HOME/vouch/config.json` and `$XDG_STATE_HOME/vouch/cookie.txt`, defaulting to `~/.config` and `~/.local/state`. Run `vouch login` first.

`make test-claims` is a regression guard on the shape of a Vouch access token (ES256, `typ: at+jwt`, JWKS-verifiable, `aud` = the requesting client unless narrowed by an RFC 8707 `resource` parameter). Every example's token verification depends on those facts, so if Vouch ever changes them this fails first.

## Adding a New Example

Expand Down
30 changes: 26 additions & 4 deletions a2a/python-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This example demonstrates:

## How It Works

1. A client agent fetches `/.well-known/agent.json` to discover this agent's capabilities
1. A client agent fetches `/.well-known/agent-card.json` to discover this agent's capabilities
2. The Agent Card declares `openIdConnect` security pointing at your Vouch issuer
3. The client obtains an access token from Vouch (via any OAuth flow)
4. The client calls the agent with `Authorization: Bearer <token>`
Expand All @@ -35,21 +35,43 @@ docker run -p 3000:3000 \

| Path | Auth Required | Description |
|------|---------------|-------------|
| `GET /.well-known/agent.json` | No | Agent Card (discovery) |
| `GET /.well-known/agent-card.json` | No | Agent Card (discovery) |
| `POST /` | Yes (Bearer) | A2A JSON-RPC endpoint |

## Agent Card

The agent card at `/.well-known/agent.json` includes:
The agent card at `/.well-known/agent-card.json` includes:

```json
{
"securitySchemes": {
"vouch_oidc": {
"openIdConnectSecurityScheme": {
"openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration"
},
"type": "openIdConnect",
"openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration"
}
},
"security": [{ "vouch_oidc": [] }]
"securityRequirements": [{ "schemes": { "vouch_oidc": {} } }]
}
```

The card's types are protobuf-backed since a2a-sdk 1.0, so the scheme is emitted in
its ProtoJSON form. The SDK also flattens `type` and `openIdConnectUrl` alongside it
for clients written against the older schema.

## Protocol Version

Built on a2a-sdk 1.x, which speaks protocol `1.0`. The JSON-RPC method is
`SendMessage`; this agent also enables v0.3 compatibility on the same endpoint, so
`message/send` works too. The older `tasks/send` is not served.

```bash
curl -X POST http://localhost:3000/ \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{
"role":"user","parts":[{"kind":"text","text":"Who am I?"}],
"messageId":"1","kind":"message"}}}'
```
70 changes: 44 additions & 26 deletions a2a/python-agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@
import jwt
from jwt import PyJWKClient
import uvicorn
from a2a.helpers import new_text_message
from a2a.server.agent_execution import AgentExecutor
from a2a.server.apps import A2AStarletteApplication
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import (
AgentCard,
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
OpenIdConnectSecurityScheme,
Role,
SecurityRequirement,
SecurityScheme,
)
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse

Expand Down Expand Up @@ -56,13 +66,14 @@ class IdentityAgentExecutor(AgentExecutor):
"""A simple agent that returns the caller's verified identity."""

async def execute(self, context, event_queue):
# Return the caller's identity info
result = {
'message': 'Identity verified via Vouch OIDC',
'note': 'The caller was authenticated with a hardware security key',
}
# A message rather than an artifact: v1.0 enforces the streaming rules, so
# emitting an artifact with no preceding Task event is now an error.
await event_queue.enqueue_event(
context.create_text_artifact(json.dumps(result, indent=2))
new_text_message(json.dumps(result, indent=2), role=Role.ROLE_AGENT)
)

async def cancel(self, context, event_queue):
Expand All @@ -73,10 +84,16 @@ async def cancel(self, context, event_queue):
agent_card = AgentCard(
name='Vouch Identity Agent',
description='An A2A agent secured with Vouch OIDC. Demonstrates hardware-backed authentication for agent-to-agent communication.',
url=f'http://localhost:{PORT}',
# `url` was replaced by supported_interfaces in v1.0.
supported_interfaces=[
AgentInterface(
url=f'http://localhost:{PORT}{DEFAULT_RPC_URL}',
protocol_binding='JSONRPC',
),
],
version='1.0.0',
defaultInputModes=['text/plain'],
defaultOutputModes=['text/plain'],
default_input_modes=['text/plain'],
default_output_modes=['text/plain'],
capabilities=AgentCapabilities(streaming=False),
skills=[
AgentSkill(
Expand All @@ -87,33 +104,29 @@ async def cancel(self, context, event_queue):
examples=['Who am I?', 'Verify my identity'],
),
],
securitySchemes={
'vouch_oidc': {
'type': 'openIdConnect',
'openIdConnectUrl': f'{VOUCH_ISSUER}/.well-known/openid-configuration',
},
# The card's types are protobuf-backed in v1.0, so the security scheme is a typed
# message rather than the plain dict the 0.2 SDK accepted.
security_schemes={
'vouch_oidc': SecurityScheme(
open_id_connect_security_scheme=OpenIdConnectSecurityScheme(
open_id_connect_url=f'{VOUCH_ISSUER}/.well-known/openid-configuration',
),
),
},
security=[{'vouch_oidc': []}],
security_requirements=[SecurityRequirement(schemes={'vouch_oidc': {'list': []}})],
)

# Create the A2A application
task_store = InMemoryTaskStore()
handler = DefaultRequestHandler(
agent_executor=IdentityAgentExecutor(),
task_store=task_store,
)
a2a_app = A2AStarletteApplication(
agent_card=agent_card,
http_handler=handler,
)

# Wrap with auth middleware
from starlette.middleware.base import BaseHTTPMiddleware


async def auth_middleware(request: Request, call_next):
# Allow unauthenticated access to agent card discovery
if request.url.path == '/.well-known/agent.json':
if request.url.path == AGENT_CARD_WELL_KNOWN_PATH:
return await call_next(request)

claims = verify_bearer_token(request)
Expand All @@ -127,12 +140,17 @@ async def auth_middleware(request: Request, call_next):
return await call_next(request)


app = a2a_app.build()

# Add middleware
app.add_middleware(BaseHTTPMiddleware, dispatch=auth_middleware)
# A2AStarletteApplication was removed in v1.0; compose the routes directly. Middleware
# now goes in the Starlette constructor rather than being added after build().
app = Starlette(
routes=[
*create_agent_card_routes(agent_card),
*create_jsonrpc_routes(handler, rpc_url=DEFAULT_RPC_URL, enable_v0_3_compat=True),
],
middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)],
)

if __name__ == '__main__':
print(f'A2A agent running on http://localhost:{PORT}')
print(f'Agent Card: http://localhost:{PORT}/.well-known/agent.json')
print(f'Agent Card: http://localhost:{PORT}{AGENT_CARD_WELL_KNOWN_PATH}')
uvicorn.run(app, host='0.0.0.0', port=PORT)
3 changes: 2 additions & 1 deletion a2a/python-agent/requirements.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
a2a-sdk[http-server]>=0.2.0,<0.3
a2a-sdk[http-server]>=1.1,<2
uvicorn>=0.51
PyJWT>=2.13
cryptography>=49.0
httpx>=0.28
starlette>=1.3
61 changes: 36 additions & 25 deletions a2a/python-agent/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
# This file was autogenerated by uv via the following command:
# uv pip compile a2a/python-agent/requirements.in --universal --python-version 3.14 -o a2a/python-agent/requirements.txt
a2a-sdk==0.2.16
a2a-sdk==1.1.2
# via -r a2a/python-agent/requirements.in
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.14.2
Expand All @@ -15,16 +13,27 @@ certifi==2026.7.22
# via
# httpcore
# httpx
# requests
cffi==2.1.0 ; platform_python_implementation != 'PyPy'
# via cryptography
charset-normalizer==3.4.9
# via requests
click==8.4.2
# via uvicorn
colorama==0.4.6 ; sys_platform == 'win32'
# via click
cryptography==49.0.0
# via -r a2a/python-agent/requirements.in
fastapi==0.140.13
# via
# -r a2a/python-agent/requirements.in
# google-auth
google-api-core==2.33.0
# via a2a-sdk
google-auth==2.56.2
# via google-api-core
googleapis-common-protos==1.75.0
# via
# a2a-sdk
# google-api-core
h11==0.16.0
# via
# httpcore
Expand All @@ -35,50 +44,52 @@ httpx==0.28.1
# via
# -r a2a/python-agent/requirements.in
# a2a-sdk
httpx-sse==0.4.3
# via a2a-sdk
idna==3.18
# via
# anyio
# httpx
opentelemetry-api==1.44.0
# requests
json-rpc==1.15.0
# via a2a-sdk
packaging==26.2
# via a2a-sdk
proto-plus==1.28.2
# via google-api-core
protobuf==6.33.6
# via
# a2a-sdk
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-sdk==1.44.0
# via a2a-sdk
opentelemetry-semantic-conventions==0.65b0
# via opentelemetry-sdk
# google-api-core
# googleapis-common-protos
# proto-plus
pyasn1==0.6.4
# via pyasn1-modules
pyasn1-modules==0.4.2
# via google-auth
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
# via cffi
pydantic==2.13.4
# via
# a2a-sdk
# fastapi
# via a2a-sdk
pydantic-core==2.46.4
# via pydantic
pyjwt==2.13.0
# via -r a2a/python-agent/requirements.in
requests==2.34.2
# via google-api-core
sse-starlette==3.4.6
# via a2a-sdk
starlette==1.3.1
# via
# -r a2a/python-agent/requirements.in
# a2a-sdk
# fastapi
# sse-starlette
typing-extensions==4.16.0
# via
# fastapi
# opentelemetry-api
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# pydantic
# via pydantic
urllib3==2.7.0
# via requests
uvicorn==0.51.0
# via -r a2a/python-agent/requirements.in
13 changes: 1 addition & 12 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,8 @@ mcp/*)

a2a/*)
start
# Path moved to agent-card.json in a2a-sdk 0.3.0; accept either until we migrate.
wait_http "/.well-known/agent-card.json" >/dev/null
port="$(port_of)"
card_ok=0
for p in /.well-known/agent-card.json /.well-known/agent.json; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "http://localhost:${port}${p}" 2>/dev/null || true)
if [ "$code" = "200" ]; then
echo " GET $p -> 200"
card_ok=1
break
fi
done
[ "$card_ok" = "1" ] || fail "agent card not served at either well-known path"
expect_status "/.well-known/agent-card.json" '^200$'
expect_post_status "/" '^401$'
;;

Expand Down
16 changes: 12 additions & 4 deletions tests/tests/a2a.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ for (const example of A2A_EXAMPLES) {

test("agent card has OIDC security scheme", async () => {
const res = await fetch(
`${baseUrl}/.well-known/agent.json`,
`${baseUrl}/.well-known/agent-card.json`,
);
expect(res.status).toBe(200);

Expand Down Expand Up @@ -142,12 +142,15 @@ for (const example of A2A_EXAMPLES) {
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tasks/send",
// `message/send` is the v0.3 method name, served here because the agent
// enables v0_3 compat. The native 1.0 name is `SendMessage`.
method: "message/send",
params: {
id: "test-task-1",
message: {
role: "user",
parts: [{ type: "text", text: "Who am I?" }],
parts: [{ kind: "text", text: "Who am I?" }],
messageId: "test-message-1",
kind: "message",
},
},
}),
Expand All @@ -156,6 +159,11 @@ for (const example of A2A_EXAMPLES) {
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("jsonrpc", "2.0");

// A JSON-RPC error is also 200 with jsonrpc:"2.0", so assert the executor
// actually ran and produced its identity message.
expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined();
expect(JSON.stringify(body.result)).toContain("Identity verified via Vouch OIDC");
});
});
}
Loading