Walkthroughs for exercising the A2A caller auth paths end-to-end against a
local server + echo client. After #31 all paths converge on opaque caller
tokens (vbc_caller_*); they differ only in how the token is issued:
direct DB insert (Path A — smoke test), Google device flow (Path B), or SIWE
exchange (Path C).
For validating a deployed bridge from a local workstation (no DB access, no admin wallet required), see
remote-testing.md.
- PostgreSQL 15+ running locally
- Node 20+, pnpm 9
- (Path B only) Google Cloud OAuth 2.0 Client ID, type Web application,
authorized redirect URI
http://localhost:8787/oauth/google/callback, with your Google account added to the consent screen's test users - (SIWE regression only) a throwaway EOA private key — see script below
Minimum (Path A, no Google):
export DATABASE_URL="postgres://$USER@localhost:5432/vicoop_bridge_dev"
export DB_SETUP_URL="$DATABASE_URL"
export ANTHROPIC_API_KEY="sk-ant-..." # admin agent
export PUBLIC_URL="http://localhost:8787"
export ADMIN_WALLET_ADDRESSES="0x0000000000000000000000000000000000000001"
export PORT=8787
export POSTGRAPHILE_PORT=5433Add for Path B (Google device flow):
export GOOGLE_CLIENT_ID="...apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="GOCSPX-..."
export DEVICE_FLOW_STATE_SECRET="$(openssl rand -hex 32)"All four of GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, DEVICE_FLOW_STATE_SECRET,
PUBLIC_URL must be set together or not at all — cli.ts fails fast otherwise.
When unset, the /oauth/* routes are not mounted (returns 404).
createdb vicoop_bridge_dev
psql vicoop_bridge_dev -c "CREATE EXTENSION IF NOT EXISTS pgcrypto;"
# schema.sql is applied automatically on server startup via ensureSchema().pnpm --filter @vicoop-bridge/server dev
# look for:
# [server] schema ensured
# [server] listening on :8787
# [server] PostGraphile listening on :5433Creates a clients row directly via SQL (bypasses SIWE admin UI), then runs
vicoop-client with the echo backend so we have a real connected agent to
route requests to.
# 1. Pick a raw token and insert the hashed form into clients.
TOKEN=dev-client-token-raw-12345
psql vicoop_bridge_dev <<SQL
INSERT INTO clients (owner_principal, client_name, token_hash, allowed_agent_ids)
VALUES (
'eth:0x0000000000000000000000000000000000000001',
'dev-echo-client',
encode(digest('$TOKEN', 'sha256'), 'hex'),
ARRAY['echo-agent']
) ON CONFLICT (token_hash) DO NOTHING;
SQL
# 2. Run the client. NOTE: --server is the base URL WITHOUT /connect — the
# client appends /connect itself (client.ts:39). Pass ws:// not http://.
cd packages/client
../../node_modules/.bin/tsx src/cli.ts \
--server ws://localhost:8787 \
--token dev-client-token-raw-12345 \
--agentId echo-agent \
--backend echo
# logs: [client] connected, sending helloThe WS registration auto-creates an agent_policies row with
allowed_callers = '{}' (public). Two ways to populate it:
vicoop-client agent callers add(Paths B/C below) — hot-reloads viaregistry.updateAllowedCallers, no restart needed. Requires an owner-session bearer (issued via SIWE exchange or Google device flow).- Raw SQL
UPDATE agent_policies SET allowed_callers = …(Path A below) — bypasses the registry, so the connected client must be killed and rerun to pick up the new list. Useful when you want to skip the auth flow entirely for the smoke test.
Covers agent-auth.ts dispatch and matchPrincipal without touching Google.
# 1. Insert a callers row with a known raw token.
CALLER_TOKEN=vbc_caller_dev_test_opaque_token_raw_12345
psql vicoop_bridge_dev <<SQL
INSERT INTO callers (token_hash, principal_id, provider, email, expires_at)
VALUES (
encode(digest('$CALLER_TOKEN', 'sha256'), 'hex'),
'google:1234567890',
'google',
'alice@example.com',
now() + interval '1 day'
);
UPDATE agent_policies
SET allowed_callers = ARRAY['google:sub:1234567890']
WHERE agent_id = 'echo-agent';
SQL
# 2. Restart the echo client so registry picks up the new allowed_callers
pkill -f 'tsx.*cli.ts.*echo-agent'
# ...rerun the client command from "Bring up an echo client" step 3...
# 3. Auth matrix
BODY='{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"messageId":"m1","role":"user","kind":"message","parts":[{"kind":"text","text":"hello"}]}}}'
# no bearer → 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8787/agents/echo-agent \
-H "Content-Type: application/json" -d "$BODY"
# wrong bearer → 401 "Caller token not found"
curl -s -X POST http://localhost:8787/agents/echo-agent \
-H "Authorization: Bearer vbc_caller_WRONG" \
-H "Content-Type: application/json" -d "$BODY"
# valid bearer, principal not in list → 403
# (first insert another caller row with a different principal_id)
# valid bearer matching google:sub:1234567890 → 200 echo
curl -s -X POST http://localhost:8787/agents/echo-agent \
-H "Authorization: Bearer $CALLER_TOKEN" \
-H "Content-Type: application/json" -d "$BODY"Full RFC-8628 flow. Requires Google env vars (above) and a real Google account that's a test user on the OAuth consent screen.
# 1. Kick off device flow
curl -sX POST http://localhost:8787/oauth/device/code | tee /tmp/device.json
# { "device_code": "...", "user_code": "XXXX-XXXX", "verification_uri_complete": "...", ... }
DEVICE_CODE=$(jq -r .device_code /tmp/device.json)
# 2. Open the URL in a browser and approve with Google.
open "$(jq -r .verification_uri_complete /tmp/device.json)"
# Flow: /oauth/device?user_code=... → /oauth/google/start → Google consent →
# /oauth/google/callback → "Approved as <email>" page
# 3. Poll for the opaque token
curl -sX POST http://localhost:8787/oauth/token \
-d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
--data-urlencode "device_code=$DEVICE_CODE"
# while pending: {"error":"authorization_pending"}
# after approval: {"access_token":"vbc_caller_...","token_type":"Bearer","expires_in":...}
# 4. Find the Google sub that was bound to your token
psql vicoop_bridge_dev -c \
"SELECT principal_id, email, expires_at FROM callers ORDER BY created_at DESC LIMIT 1;"
# principal_id will be 'google:<sub>' — keep the sub for step 5b.
# 5a. Get an owner-session bearer. Saves the token to ~/.vicoop/owner-session.json
# so 5b picks it up automatically. Re-using the same Google account is
# fine; the audiences are independent.
# (`CLI` is just shorthand for the local-source invocation — see the
# callout below if you're working from a published bundle.)
CLI="pnpm --filter @vicoop-bridge/client exec tsx src/cli.ts"
$CLI auth login --bridge http://localhost:8787
# 5b. Add the caller principal — hot-reloads via registry.updateAllowedCallers,
# no client restart needed.
$CLI agent callers add echo-agent "google:sub:<YOUR_SUB>"
# 6. Call the agent with the issued Bearer
TOKEN="vbc_caller_..." # from step 3
curl -s -X POST http://localhost:8787/agents/echo-agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"messageId":"m","role":"user","kind":"message","parts":[{"kind":"text","text":"hi"}]}}}'Working from a published client bundle instead of source? Swap
$CLIfor"$INSTALL_DIR/vicoop-client"(the pathdocs/install-client.mdsets up). The subcommands are identical.
Other entry formats:
google:email:<your_email>— matches on verified emailgoogle:domain:<your_workspace_domain>— any verified account from the domain (matches eitherhdclaim or@domainemail suffix)
Raw SIWE bearers are no longer accepted on /agents/:id, POST /, or admin
GraphQL (see #31). SIWE is now an issuance method: sign a message, POST it
to /auth/siwe/exchange, receive an opaque vbc_caller_* token, and use that
token on every subsequent request. This section signs programmatically with a
test EOA and walks the full exchange.
# Script uses a well-known Anvil test key — safe because it has no real balance.
cat > /tmp/gen-siwe.mjs <<'JS'
import { SiweMessage } from 'siwe';
import { privateKeyToAccount } from 'viem/accounts';
// Must match the server's PUBLIC_URL hostname — the exchange endpoint enforces
// domain match against siweDomain derived from PUBLIC_URL.
const DOMAIN = process.env.SIWE_DOMAIN ?? 'localhost';
const URI = process.env.SIWE_URI ?? 'http://localhost:8787';
// Anvil account #0 — public test key, DO NOT use for anything real.
const PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const account = privateKeyToAccount(PRIVATE_KEY);
console.error(`# wallet: ${account.address}`);
// Fresh nonce per invocation: `/auth/siwe/exchange` enforces single-use per
// (principal, nonce), so a fixed literal would cause the second run to fail.
const nonce = crypto.randomUUID().replace(/-/g, '');
const msg = new SiweMessage({
domain: DOMAIN,
address: account.address,
statement: 'Regression test',
uri: URI,
version: '1',
chainId: 1,
nonce,
issuedAt: new Date().toISOString(),
expirationTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
const message = msg.prepareMessage();
const signature = await account.signMessage({ message });
// Emit JSON to stdout so it can be piped directly into the exchange endpoint.
console.log(JSON.stringify({ message, signature }));
JS
# Run from packages/admin-ui — it's the only workspace with viem resolvable.
cp /tmp/gen-siwe.mjs packages/admin-ui/
(cd packages/admin-ui && node gen-siwe.mjs) > /tmp/siwe.json
# Exchange SIWE → opaque caller token. The exchange endpoint defaults to
# audience='owner_session' (vbc_owner_*) when intent is omitted, so for the
# /agents/:id call below we ask explicitly for intent=caller.
TOKEN=$(curl -sX POST http://localhost:8787/auth/siwe/exchange \
-H "Content-Type: application/json" \
--data "$(jq -c '. + {intent: "caller"}' /tmp/siwe.json)" \
| jq -r .access_token)
echo "$TOKEN" # vbc_caller_...
# Confirm the callers row landed with provider='siwe'.
psql vicoop_bridge_dev -c \
"SELECT principal_id, provider, expires_at FROM callers ORDER BY created_at DESC LIMIT 1;"
# principal_id should be eth:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (Anvil #0, lowercased)
# provider should be 'siwe'
# Grant the wallet via the CLI (no client restart needed). Two-step:
# (a) sign a fresh SIWE for an owner-session bearer
# (b) add the eth:* principal to the agent's allowed_callers
# The step-3 exchange asked for intent=caller; here we use the exchange
# endpoint's owner_session default (intent omitted) for the policy bearer.
# Re-run /tmp/gen-siwe.mjs to mint a fresh nonce — single-use per exchange.
(cd packages/admin-ui && node gen-siwe.mjs) > /tmp/siwe-owner.json
OWNER_TOKEN=$(curl -sX POST 'http://localhost:8787/auth/siwe/exchange' \
-H 'Content-Type: application/json' \
--data @/tmp/siwe-owner.json \
| jq -r .access_token)
echo "$OWNER_TOKEN" # vbc_owner_...
CLI="pnpm --filter @vicoop-bridge/client exec tsx src/cli.ts"
VICOOP_BRIDGE=http://localhost:8787 VICOOP_OWNER_TOKEN="$OWNER_TOKEN" \
$CLI agent callers add echo-agent \
'eth:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'
# Call the agent with the caller-audience opaque token from step 3.
curl -s -X POST http://localhost:8787/agents/echo-agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"messageId":"m","role":"user","kind":"message","parts":[{"kind":"text","text":"siwe test"}]}}}'Verified scenarios:
- exchange issues an opaque token tied to
eth:<addr>withprovider='siwe' - that opaque token matches canonical
eth:0x...entries inallowed_callers - mixed policy (
[eth:0x..., google:sub:...]) — both opaque tokens match against their own principal - raw SIWE bearer on
/agents/:id→ 401Invalid bearer token: expected vbc_caller_* prefix - expired SIWE message on exchange → 401
invalid_grant - domain mismatch on exchange → 401
invalid_grant - admin UI / admin GraphQL: same opaque token grants
principal_idclaim and (if the eth: address is inADMIN_WALLET_ADDRESSES) admin scope
DB-gated cases in the auth module tests (caller-token, device-flow,
siwe-exchange) skip without DATABASE_URL. To run the full suite:
DATABASE_URL="postgres://$USER@localhost:5432/vicoop_bridge_dev" \
pnpm --filter @vicoop-bridge/server exec tsx --test src/auth/*.test.ts
# expect: pass 90 / skipped 0allowed_callersedits via raw SQL don't hot-reload —registry.tscaches the list in memory at WS registration. After apsqlUPDATE(Path A), kill and re-run the echo client so it re-registers and the registry re-reads the row. Thevicoop-client agent callers add/removesubcommands and the admin agent'sadd_callertool callregistry.updateAllowedCallersand don't need a restart.- Client
--serverURL must not include/connect— client.ts appends it.--server ws://localhost:8787/connectresults in/connect/connectand immediate disconnects. /oauth/*endpoints 404 without Google env — the mount is conditional on all four Google config vars being present.- Multiple client processes with the same token collide; the older WS gets closed with code 4009. Kill previous clients before restarting.
device_sessionspending rows accumulate if flows are abandoned. They have a 10-min TTL and are cleaned up hourly by the background job inindex.ts. To force-purge:DELETE FROM device_sessions WHERE expires_at <= now();- Caller token LRU cache is 60s. After
UPDATE callers SET revoked=true, revocation only takes effect on the next verify after ~60s. For immediate testing, wait out the window or restart the server. - Probing the
claudeCLI directly? Pass--strict-mcp-config. When you spawnclaude -p ...by hand to reproduce a claude-backend behavior (e.g. inspectingstream-jsonframes), it otherwise loads your operator MCP servers from~/.claude/ project.mcp.json— sentry, github, etc. — which spawn extra child processes and eat CPU/RAM, and aren't part of the bridge's spawn anyway.--strict-mcp-configwith no--mcp-configuses zero MCP servers (system/initshowsmcp_servers=[]):If a probe still leaks server processes, clean up withclaude -p "..." --strict-mcp-config \ --input-format stream-json --output-format stream-json --verbose \ --include-partial-messages --model claude-opus-4-8pkill -f 'sentry-mcp'(substitute the server binary).
pkill -f 'tsx.*watch.*src/cli.ts' # server
pkill -f 'tsx.*cli.ts.*echo-agent' # client
dropdb vicoop_bridge_dev # optional, full reset