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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ yarn-debug.log*
yarn-error.log*

.cache

# compiled example binaries
with-go/with-go
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Example applications and integrations for [Authorizer](https://authorizer.dev)
| [with-microservices](./with-microservices) | Authorizer as the auth layer across multiple microservices |
| [with-openid-connect](./with-openid-connect) | Standard OpenID Connect integration against Authorizer's OIDC endpoints |
| [with-agent-delegation](./with-agent-delegation) | Delegating scoped access to AI agents with audited delegation chains |
| [with-agent-permissions](./with-agent-permissions) | Per-agent FGA permissions intersected with the delegating user's |
| [with-claude-agents](./with-claude-agents) | Two real Claude Agent SDK agents (DevOps assistant + infra agent) delegating and authorizing over HTTP, with a fail-closed OpenFGA permission gate |
| [with-mcp](./with-mcp) | Authorizer as the OAuth 2.1 authorization server protecting an MCP server (RFC 9728 + RFC 8707) |
| [with-a2a-agent-card](./with-a2a-agent-card) | A2A (Agent2Agent) v1.0 Agent Card backed by Authorizer as the OAuth2 authorization server |
Expand Down
1 change: 1 addition & 0 deletions USECASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Beyond the framework quickstarts (`with-react`, `with-nextjs`, `with-vue`, `with
| [`with-m2m-client-credentials`](./with-m2m-client-credentials) | A background worker authenticating as itself: register a service account (`_create_client`), then the OAuth2 `client_credentials` grant with scope ceilings |
| [`with-token-exchange-delegation`](./with-token-exchange-delegation) | RFC 8693 token exchange: an agent acts on behalf of a user with a short-lived, down-scoped, resource-bound token carrying the `act` claim |
| [`with-agent-delegation`](./with-agent-delegation) | Multi-hop AI-agent delegation chains built on token exchange |
| [`with-agent-permissions`](./with-agent-permissions) | Per-agent permissions: an agent's authority is `perms(agent) ∩ perms(user)` — the Confused Deputy fix |
| [`with-mcp`](./with-mcp) | MCP server protected by Authorizer as an OAuth 2.1 AS: RFC 9728 protected-resource metadata, RFC 8707 resource-bound tokens |
| [`with-a2a-agent-card`](./with-a2a-agent-card) | An A2A v1.0 Agent Card whose `securitySchemes.oauth2` points at Authorizer, authenticated as an ordinary OAuth2 resource server |
| [`with-claude-agents`](./with-claude-agents) | Two independent Claude Agent SDK agents delegating over real HTTP, gated by a fail-closed OpenFGA `can_deploy` check keyed to the user, not the agent |
Expand Down
29 changes: 28 additions & 1 deletion with-a2a-agent-card/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,18 @@ rather than authenticating as itself — mint the bearer with RFC 8693 token
exchange instead of `client_credentials`; see [`../with-agent-delegation`](../with-agent-delegation)
and [`../with-token-exchange-delegation`](../with-token-exchange-delegation).
This agent's bearer check doesn't care which grant produced the token, only
that it's a valid Authorizer-issued one.
that it's a valid Authorizer-issued one — step 6 of the walkthrough proves it,
swapping the grant while the card and the server stay untouched. Authorizer
supports the delegation profile only: an `actor_token` is required, and the
minted token keeps `sub` = the user and records the agent in `act`.

Object-level permissions are a separate concern from the card. This agent
authorizes on the scope its card demands (step 7 below); for ReBAC checks over
individual objects, see [`../with-fga-permissions`](../with-fga-permissions).
Note that a delegated, resource-bound token cannot call Authorizer's own
`check_permissions` — a resource server that needs an FGA decision for the
delegated `sub` makes it server-side with the admin credential and an explicit
`user:` subject.

## The Agent Card

Expand Down Expand Up @@ -106,6 +117,22 @@ npm start
npm run client
```

The walkthrough discovers the card, reads its oauth2 scheme, then makes four
calls against the same endpoint: no token → **401**, a `client_credentials`
token → **200**, an RFC 8693 **delegated** token (`sub` = user, `act` = agent)
→ **200**, and a valid token that lacks the scope the card requires → **403**.

Authorizer must sign with an **asymmetric** key — the agent validates bearers
against JWKS, and an HMAC deployment (`--jwt-type HS256/HS384/HS512`) publishes
an empty `jwks.json` by design, so nothing is verifiable. `make dev` uses RS256;
with an HMAC one the agent refuses to start rather than 401 every call.

The delegated step signs a throwaway user up. Since 2.4.0 MFA is on by default,
so signup withholds the access token and offers an MFA setup instead; the
walkthrough declines it with `skip_mfa_setup`, carrying the `mfa_session` cookie
by hand (it is marked `Secure`, so no client replays it over plain http). Under
`--enforce-mfa`, where declining is not permitted, that step fails by design.

Environment overrides: `AUTHORIZER_URL` (default `http://localhost:8080`),
`AGENT_URL`/`PORT` (default `http://localhost:4002`), `ADMIN_SECRET`
(default `admin`, matches `make dev`'s default admin secret).
177 changes: 140 additions & 37 deletions with-a2a-agent-card/client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
// 3. Register + authenticate as an agent (client_credentials)
// 4. Call the skill without a token -> 401
// 5. Call the skill with the token -> 200
// 6. Call the skill DELEGATED, on a user's behalf -> 200
// (RFC 8693 token exchange; the card and the bearer check are unchanged)
// 7. Call the skill with a token lacking the card's
// required scope -> 403
//
// Setup (step 3) needs the admin secret once, to register the calling agent
// as a service account. Real deployments do this from the dashboard.
Expand All @@ -17,17 +21,75 @@ const ADMIN_SECRET = process.env.ADMIN_SECRET || "admin";

const log = (step, msg) => console.log(`\n[${step}] ${msg}`);

// A token-withheld MFA offer is identified by a session cookie the server marks
// Secure, so no HTTP client replays it over plain http — carry it by hand.
let cookie = "";

async function gql(query, variables, headers = {}) {
const res = await fetch(`${AUTHORIZER_URL}/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: AUTHORIZER_URL, ...headers },
headers: {
"Content-Type": "application/json",
Origin: AUTHORIZER_URL,
...(cookie && { Cookie: cookie }),
...headers,
},
body: JSON.stringify({ query, variables }),
});
const mfa = res.headers.getSetCookie().find((c) => c.startsWith("mfa_session="));
if (mfa) cookie = mfa.split(";")[0];
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}

// /oauth/token takes FORM-ENCODED bodies.
async function oauthToken(params) {
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
});
const body = await res.json();
if (!res.ok) throw new Error(JSON.stringify(body));
return body;
}

const decodeJwt = (jwt) =>
JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());

// Register a service account and return its credentials.
async function registerAgent(name, allowed_scopes) {
const d = await gql(
`mutation ($params: CreateClientRequest!) { _create_client(params: $params) { client { client_id } client_secret } }`,
{ params: { name, allowed_scopes } },
{ "x-authorizer-admin-secret": ADMIN_SECRET }
);
return { clientId: d._create_client.client.client_id, clientSecret: d._create_client.client_secret };
}

// POST a SendMessage call, optionally bearing `token`. Non-200 is data here:
// the walkthrough asserts on rejections too.
async function callSkill(id, token) {
const res = await fetch(a2aUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({
jsonrpc: "2.0",
id,
method: "SendMessage",
// v1.0 SendMessage: params carry a REQUIRED `message` (Message with role + parts).
params: {
message: { messageId: randomUUID(), role: "ROLE_USER", parts: [{ text: "hi" }] },
},
}),
});
return { status: res.status, body: await res.json() };
}

// --- 1. Discover the Agent Card ---------------------------------------------
const card = await (await fetch(`${AGENT_URL}/.well-known/agent-card.json`)).json();
log(1, `agent: ${card.name} — ${card.description}`);
Expand All @@ -45,52 +107,93 @@ const tokenUrl = scheme.oauth2SecurityScheme.flows.clientCredentials.tokenUrl;
log(2, `security scheme "${schemeName}": client_credentials tokenUrl=${tokenUrl}`);

// --- 3. Register the calling agent as a service account, get a token -------
const created = await gql(
`mutation ($params: CreateClientRequest!) { _create_client(params: $params) { client { client_id } client_secret } }`,
{ params: { name: `a2a-demo-caller-${Date.now()}`, allowed_scopes: ["openid"] } },
{ "x-authorizer-admin-secret": ADMIN_SECRET }
const { clientId, clientSecret } = await registerAgent(
`a2a-demo-caller-${Date.now()}`,
["openid"]
);
const { client_id: clientId } = created._create_client.client;
const clientSecret = created._create_client.client_secret;
log(3, `caller agent registered: ${clientId}`);

const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
const tokenRes = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` },
body: new URLSearchParams({ grant_type: "client_credentials" }),
const token = await oauthToken({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
});
const token = await tokenRes.json();
if (!tokenRes.ok) throw new Error(JSON.stringify(token));
log(3, `client_credentials token acquired`);

// v1.0 SendMessage: params carry a REQUIRED `message` (Message with role + parts).
const sendMessage = (id) => ({
jsonrpc: "2.0",
id,
method: "SendMessage",
params: {
message: { messageId: randomUUID(), role: "ROLE_USER", parts: [{ text: "hi" }] },
},
});

// --- 4. Call the skill without a token --------------------------------------
const unauth = await fetch(a2aUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sendMessage(1)),
});
const unauth = await callSkill(1);
log(4, `call without a token -> HTTP ${unauth.status}`);
if (unauth.status !== 401) throw new Error("expected 401");

// --- 5. Call the skill with the token ---------------------------------------
const authed = await fetch(a2aUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token.access_token}` },
body: JSON.stringify(sendMessage(2)),
});
const result = await authed.json();
log(5, `call with the token -> HTTP ${authed.status}: ${JSON.stringify(result.result)}`);
const authed = await callSkill(2, token.access_token);
log(5, `call with the token -> HTTP ${authed.status}: ${JSON.stringify(authed.body.result)}`);
if (authed.status !== 200) throw new Error("expected 200");

console.log("\nDone: agent card discovery -> client_credentials -> authenticated A2A call.");
// --- 6. The same call, DELEGATED on a user's behalf --------------------------
// The Agent Card advertises client_credentials, and this server's bearer check
// only cares that the token is a valid Authorizer-issued one — so an agent that
// must act FOR a user swaps the grant, not the card. RFC 8693 token exchange,
// delegation profile: an actor_token is REQUIRED (Authorizer rejects
// impersonation), and the minted token keeps sub = the user while recording the
// agent in `act`.
const email = `a2a-demo-user+${Date.now()}@example.com`;
const password = "A2a-demo-user-1!";
const scope = ["openid"];

const signup = await gql(
`mutation ($params: SignUpRequest!) { signup(params: $params) { access_token } }`,
{ params: { email, password, confirm_password: password, scope } }
);
// Since 2.4.0 MFA is ON by default, so signup enrols nothing but OFFERS an MFA
// setup and WITHHOLDS the access token ("Proceed to mfa setup") until the user
// either enrols a factor or explicitly declines. This demo declines, which is
// what skip_mfa_setup is for: it records the refusal and releases the withheld
// token. Identification is by the MFA session cookie carried above plus the
// email, so it must run on the same client. Fails under --enforce-mfa, where
// declining is not permitted; a real app would drive the setup screen instead.
let userToken = signup.access_token;
if (!userToken) {
log(6, `signup withheld the token and offered MFA setup — declining it`);
const skipped = await gql(
`mutation ($params: SkipMfaSetupRequest!) { skip_mfa_setup(params: $params) { access_token } }`,
{ params: { email } }
);
userToken = skipped.skip_mfa_setup.access_token;
}

const delegated = await oauthToken({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
client_id: clientId,
client_secret: clientSecret,
subject_token: userToken, // whose authority is exercised
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
actor_token: token.access_token, // who is acting — REQUIRED, no impersonation
actor_token_type: "urn:ietf:params:oauth:token-type:access_token",
resource: a2aUrl, // EXACTLY ONE resource (RFC 8707)
scope: "openid",
});
const claims = decodeJwt(delegated.access_token);
log(6, `delegated token: sub=${claims.sub} act=${JSON.stringify(claims.act)} aud=${claims.aud} scope=${JSON.stringify(claims.scope)}`);
if (claims.act?.sub !== clientId) throw new Error("act.sub should be the calling agent");

const onBehalf = await callSkill(3, delegated.access_token);
log(6, `delegated call -> HTTP ${onBehalf.status}: authenticated as ${onBehalf.body.result?.message?.metadata?.authenticatedAs} (the user, not the agent)`);
if (onBehalf.status !== 200) throw new Error("expected 200");

// --- 7. A token that lacks the scope the card demands -> 403 ------------------
// The card's `security` requirement is not decoration: a perfectly valid
// Authorizer token whose scopes don't satisfy it is rejected.
const weak = await registerAgent(`a2a-demo-unscoped-${Date.now()}`, ["profile"]);
const weakToken = await oauthToken({
grant_type: "client_credentials",
client_id: weak.clientId,
client_secret: weak.clientSecret,
});
const denied = await callSkill(4, weakToken.access_token);
log(7, `valid token without scope "openid" -> HTTP ${denied.status}: ${denied.body.error} (${denied.body.error_description})`);
if (denied.status !== 403) throw new Error("expected 403");

console.log(
"\nDone: card discovery -> client_credentials -> authenticated call -> delegated call (act chain) -> scope rejection."
);
14 changes: 14 additions & 0 deletions with-a2a-agent-card/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ const AGENT_URL = process.env.AGENT_URL || `http://localhost:${PORT}`;
const oidc = await (
await fetch(`${AUTHORIZER_URL}/.well-known/openid-configuration`)
).json();

// Verifying bearers against JWKS only works if the AS signs with an ASYMMETRIC
// key. An HMAC-signed deployment (--jwt-type HS256/HS384/HS512) publishes an
// empty JWKS by design — the signing key is a shared secret — and every call
// would 401 with an opaque "no applicable key found". Fail at boot instead.
const { keys } = await (await fetch(oidc.jwks_uri)).json();
if (!keys?.length) {
throw new Error(
`${oidc.jwks_uri} publishes no keys. Start Authorizer with an asymmetric ` +
`--jwt-type (RS256/RS384/RS512/ES256/...) plus --jwt-private-key/--jwt-public-key; ` +
`an HMAC --jwt-type cannot be verified by an A2A client.`,
);
}

const jwks = createRemoteJWKSet(new URL(oidc.jwks_uri));
console.log(`[a2a-agent] trusting issuer ${oidc.issuer}, token endpoint ${oidc.token_endpoint}`);

Expand Down
50 changes: 40 additions & 10 deletions with-agent-delegation/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,24 @@ const AGENTS = [

// ---------------------------------------------------------------- helpers --

async function gql(query, variables = undefined, headers = {}) {
// Returns { data, setCookies } — setCookies carries the MFA session cookie
// that skip_mfa_setup needs (see getUserToken).
async function gqlFull(query, variables = undefined, headers = {}) {
const res = await fetch(`${BASE}/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: ORIGIN, ...headers },
body: JSON.stringify({ query, variables }),
});
const body = await res.json();
if (body.errors) throw new Error(body.errors.map((e) => e.message).join("; "));
return body.data;
return { data: body.data, setCookies: res.headers.getSetCookie() };
}

const gql = (q, v, h) => gqlFull(q, v, h).then((r) => r.data);

// Turn Set-Cookie response headers into a Cookie request header value.
const cookieHeader = (setCookies) => setCookies.map((c) => c.split(";")[0]).join("; ");

const adminGql = (q, v) => gql(q, v, { "x-authorizer-admin-secret": ADMIN_SECRET });

// POST /oauth/token (form-encoded). Returns { status, body } — negative cases
Expand Down Expand Up @@ -113,15 +120,38 @@ function expectRejection(label, { status, body }) {

// Login as the demo user; sign up on first run. If the email exists with a
// different password (a shared dev database), delete and recreate it.
// Since 2.4.0 MFA is on by default, so signup/login enrol nothing but OFFER an
// MFA setup: no access token, and the message "Proceed to mfa setup", until the
// user either enrols a factor or explicitly declines. This demo is about
// delegation, not enrollment, so it declines — that is what skip_mfa_setup is
// for. The call is identified by the MFA session cookie the previous response
// set, plus the email. Under --enforce-mfa declining is refused and the user
// must enrol instead.
async function settleMfaOffer(auth, setCookies) {
if (auth?.access_token) return auth.access_token;
const { data } = await gqlFull(
`mutation ($params: SkipMfaSetupRequest!) { skip_mfa_setup(params: $params) { access_token } }`,
{ params: { email: USER_EMAIL } },
{ Cookie: cookieHeader(setCookies) }
);
return data.skip_mfa_setup.access_token;
}

async function getUserToken() {
const login = () =>
gql(`mutation ($params: LoginRequest!) { login(params: $params) { access_token } }`, {
params: { email: USER_EMAIL, password: USER_PASSWORD, scope: USER_SCOPES },
}).then((d) => d.login.access_token);
const signup = () =>
gql(`mutation ($params: SignUpRequest!) { signup(params: $params) { access_token } }`, {
params: { email: USER_EMAIL, password: USER_PASSWORD, confirm_password: USER_PASSWORD, scope: USER_SCOPES },
}).then((d) => d.signup.access_token);
const login = async () => {
const { data, setCookies } = await gqlFull(
`mutation ($params: LoginRequest!) { login(params: $params) { access_token } }`,
{ params: { email: USER_EMAIL, password: USER_PASSWORD, scope: USER_SCOPES } }
);
return settleMfaOffer(data.login, setCookies);
};
const signup = async () => {
const { data, setCookies } = await gqlFull(
`mutation ($params: SignUpRequest!) { signup(params: $params) { access_token } }`,
{ params: { email: USER_EMAIL, password: USER_PASSWORD, confirm_password: USER_PASSWORD, scope: USER_SCOPES } }
);
return settleMfaOffer(data.signup, setCookies);
};

try {
return await login();
Expand Down
17 changes: 17 additions & 0 deletions with-agent-permissions/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Credentials for gemini-agent.mjs — copy to .env and fill ONE of them.
# .env is gitignored. Never commit a real key.

# Native Google AI Studio (https://aistudio.google.com/apikey)
GEMINI_API_KEY=
# Optional; defaults to gemini-2.0-flash
# GEMINI_MODEL=gemini-2.0-flash

# ...or OpenRouter, routed to a Gemini model (https://openrouter.ai/keys)
# Used only when GEMINI_API_KEY is empty.
OPENROUTER_API_KEY=
# Optional; defaults to google/gemini-2.0-flash-001
# OPENROUTER_MODEL=google/gemini-2.0-flash-001

# Optional: point the demo at a non-default Authorizer
# AUTHORIZER_URL=http://localhost:8080
# AUTHORIZER_ADMIN_SECRET=admin
Loading