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
50 changes: 49 additions & 1 deletion docs/agent-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,50 @@ SAME verified-principal slot — the principal id is the **SPIFFE ID** itself
tried only when no live agent token resolves; both are gated off in
`api-key-only` mode.

## Delegated / on-behalf-of tokens — RFC-8693 (#43)

Unattended agent chains — an orchestrator **A** that spawns a sub-agent **B** to
act for it — need a token that records both *who is acting* (B) and *on whose
behalf* (A). AgentGate adds the [RFC-8693](https://datatracker.ietf.org/doc/html/rfc8693)
**token-exchange** grant at `POST /api/agents/token` to mint such **delegated
tokens**. Tier 1 supports **delegation** (an `act` chain is always left behind);
impersonation (no `act`) is intentionally not offered.

**Enable:** set `AGENT_TOKEN_EXCHANGE_ENABLED=true`. Delegated tokens are
**RS256-only**, so `AGENT_TOKEN_SIGNING_KEY` (#40) must also be configured — with
neither, the grant returns `unsupported_grant_type`.

**Exchange:** the actor presents **both** its own agent token (`actor_token`) and
the subject's agent token (`subject_token`, given to it by the subject):

```
POST /api/agents/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<A's agent token> # the principal (on behalf of whom)
actor_token=<B's agent token> # the actor (who is acting)
→ { access_token, issued_token_type: "urn:ietf:params:oauth:token-type:jwt", token_type: "Bearer", expires_in }
```

**Claim shape (strict RFC-8693 §4.1):** the delegated token keeps the on-behalf-of
principal in `sub` and the current actor in the top-level `act.sub`; a chain nests
older actors inward. Both AgentGate and AgentLens read the **actor** via
`actingAgentOf()` (`act.sub ?? sub`), so per-agent budgets (#13), MCP guardrails
(#14), virtual-key binding, and AgentLens attribution all target **B (the actor)**,
while `sub` records who B acts for. A plain (non-delegated) token has no `act`, so
`sub` is the actor — existing tokens and verifiers are unchanged.

- **Verification:** both presented tokens are verified as agent tokens; the actor
must be a **live, non-revoked** `agt_*` at exchange time (mirroring the use-time
liveness re-check). Possession of a valid subject + actor token IS the
authorization — this is never worse than the actor simply using the subject's
token directly, but leaves an audit trace instead.
- **Attribution:** AgentLens stamps the principal into `verifiedOnBehalfOf`
alongside `verifiedAgentId` (the actor), so a delegated purchase reads as
"B acting for A".
- **Limitation (deferred):** no `may_act` restriction on *which* agents may act
for a subject (Tier 2), and no scope/audience narrowing of the delegated token
(Tier 3). A crafted delegation chain is bounded (`MAX_DELEGATION_DEPTH`).

## Downstream propagation (the consumer contract)

The same verified identity is intended to flow to the rest of the platform.
Expand Down Expand Up @@ -249,6 +293,7 @@ formalization, not a new subsystem.
| `AGENT_TOKEN_VERIFY_KEYS` | — | JSON array of public RSA JWKs (each with `kid`) to also publish + accept on verify — retired signers during a rotation. |
| `AGENT_TOKEN_AUDIENCE` | — | `aud` claim minted into RS256 tokens and required on verify (RS256 path only). |
| `AGENT_TOKEN_ISSUER` | — | `iss` claim minted into RS256 tokens for issuer pinning (RS256 path only). |
| `AGENT_TOKEN_EXCHANGE_ENABLED` | `false` | Enable the RFC-8693 token-exchange grant (delegated/on-behalf-of tokens, #43). Requires `AGENT_TOKEN_SIGNING_KEY` (RS256-only). |
| `SPIFFE_AUDIENCE` | — | Required SVID audience. Set with `SPIFFE_TRUST_DOMAIN` + a bundle to enable SPIFFE. |
| `SPIFFE_TRUST_DOMAIN` | — | Trust domain to pin SVID subjects to (e.g. `example.org`). Required to enable SPIFFE. |
| `SPIFFE_JWKS_URL` | — | URL of the SPIFFE trust bundle (JWKS). Validated as a URL. |
Expand Down Expand Up @@ -282,6 +327,9 @@ formalization, not a new subsystem.
[above](#external-spiffe--wimse-workload-identity-41). Still open: X.509-SVID
(Workload API / mTLS), and **binding a SPIFFE ID to a registered `agt_*`** so
per-agent budgets/policies apply.
- **RFC-8693 token exchange** for delegated/on-behalf-of agent chains.
- ✅ **RFC-8693 token exchange** for delegated/on-behalf-of agent chains — shipped
in #43 (Tier 1: delegation via `subject_token` + `actor_token`, strict `act`
chain, RS256-only). See [above](#delegated--on-behalf-of-tokens--rfc-8693-43).
Still open: `may_act` restriction (Tier 2) and scope/audience narrowing (Tier 3).
- **Token-endpoint rate limiting** and a credential-rotation playbook
(dual-active secret window).
280 changes: 280 additions & 0 deletions packages/server/src/__tests__/token-exchange.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
/**
* RFC-8693 token exchange — delegated agent tokens (#43, Tier 1).
*
* Covers the pure claim/chain builder, the RS256 mint + verify round-trip, the
* load-bearing extraction (a delegated token is enforced against the ACTOR, not
* the on-behalf-of principal), and the grant at POST /api/agents/token incl. its
* fail-closed cases (disabled, missing actor_token, invalid tokens, revoked actor).
*/
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { generateKeyPair, exportPKCS8, decodeJwt } from "jose";
import { type AuthConfig } from "@agentkitai/auth";
import * as schema from "../db/schema.js";

// agent-tokens.ts → agents.js → db/index.js at import time; give it a real in-memory DB.
const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema });

sqlite.exec(`
CREATE TABLE IF NOT EXISTS agents (
id text PRIMARY KEY NOT NULL,
name text NOT NULL,
secret_hash text NOT NULL,
status text NOT NULL,
metadata text,
created_at integer NOT NULL,
last_seen_at integer,
revoked_at integer,
monthly_budget_usd real,
ingest_key_hash text
);
`);

vi.mock("../db/index.js", () => ({ getDb: () => db }));

import {
signAgentToken,
verifyAgentToken,
verifyAgentTokenClaims,
actingAgentOf,
onBehalfOfAgent,
} from "../lib/agent-tokens.js";
import {
buildDelegatedClaims,
delegationDepth,
mintDelegatedToken,
MAX_DELEGATION_DEPTH,
} from "../lib/token-exchange.js";
import { createAgent, revokeAgent } from "../lib/agents.js";
import { __resetAgentTokenKeysCache } from "../lib/agent-token-keys.js";
import agentTokensRouter from "../routes/agent-tokens.js";
import { parseConfig, setConfig, resetConfig } from "../config.js";

const TEST_SECRET = "test-jwt-secret-at-least-32-chars-long!!";

function authConfig(): AuthConfig {
return {
oidc: null,
jwt: { secret: TEST_SECRET, accessTokenTtlSeconds: 900, refreshTokenTtlSeconds: 604800 },
authDisabled: false,
};
}

let SIGNING_PEM: string;

/** Configure RS256 signing + the exchange flag, clearing the key cache. */
function configure(overrides: Record<string, unknown> = {}) {
setConfig(
parseConfig({
jwtSecret: TEST_SECRET,
agentTokenSigningKey: SIGNING_PEM,
agentTokenExchangeEnabled: true,
...overrides,
}),
);
__resetAgentTokenKeysCache();
}

beforeEach(async () => {
if (!SIGNING_PEM) {
const { privateKey } = await generateKeyPair("RS256", { extractable: true });
SIGNING_PEM = await exportPKCS8(privateKey);
}
sqlite.exec("DELETE FROM agents");
configure();
});

afterAll(() => {
resetConfig();
__resetAgentTokenKeysCache();
sqlite.close();
});

// ─── pure claim/chain builder ────────────────────────────────────────

describe("buildDelegatedClaims + delegationDepth", () => {
it("simple delegation: sub = principal, act.sub = actor", () => {
const built = buildDelegatedClaims({ sub: "agt_A" }, { sub: "agt_B" });
expect(built).toEqual({ sub: "agt_A", act: { sub: "agt_B" } });
});

it("chains: preserves the ORIGINAL principal in sub, nests prior actors", () => {
// subject_token already delegated (X's token, currently acting as A)…
const subject = { sub: "agt_X", act: { sub: "agt_A" } };
// …now A delegates to B.
const built = buildDelegatedClaims(subject, { sub: "agt_B" });
expect(built).toEqual({ sub: "agt_X", act: { sub: "agt_B", act: { sub: "agt_A" } } });
expect(delegationDepth(built!.act)).toBe(2);
});

it("uses the actor's ACTING agent when the actor_token is itself delegated", () => {
const built = buildDelegatedClaims({ sub: "agt_A" }, { sub: "agt_P", act: { sub: "agt_B" } });
expect(built!.act.sub).toBe("agt_B"); // act.sub, not sub
});

it("rejects a chain deeper than the cap", () => {
let subject: { sub: string; act?: unknown } = { sub: "agt_root" };
for (let i = 0; i < MAX_DELEGATION_DEPTH; i++) {
subject = buildDelegatedClaims(subject as never, { sub: `agt_${i}` })!;
}
// One more would exceed the cap.
expect(buildDelegatedClaims(subject as never, { sub: "agt_last" })).toBeNull();
});
});

describe("actingAgentOf / onBehalfOfAgent", () => {
it("plain token: actor = sub, onBehalfOf = undefined", () => {
expect(actingAgentOf({ sub: "agt_A" })).toBe("agt_A");
expect(onBehalfOfAgent({ sub: "agt_A" })).toBeUndefined();
});
it("delegated token: actor = act.sub, onBehalfOf = sub", () => {
const c = { sub: "agt_A", act: { sub: "agt_B" } };
expect(actingAgentOf(c)).toBe("agt_B");
expect(onBehalfOfAgent(c)).toBe("agt_A");
});
});

// ─── mint + verify round-trip ────────────────────────────────────────

describe("mintDelegatedToken + verify", () => {
it("mints an RS256 delegated token that verifies to the ACTOR (enforcement) + carries the principal", async () => {
const token = await mintDelegatedToken({
subjectClaims: { sub: "agt_A" },
actorClaims: { sub: "agt_B" },
config: authConfig(),
});
expect(token).toBeTruthy();

// verifyAgentToken returns the ACTING agent → all enforcement targets B.
expect(await verifyAgentToken(token!, authConfig())).toBe("agt_B");

const claims = await verifyAgentTokenClaims(token!, authConfig());
expect(actingAgentOf(claims!)).toBe("agt_B");
expect(onBehalfOfAgent(claims!)).toBe("agt_A");
expect(decodeJwt(token!)["typ"]).toBe("agent"); // discriminator preserved
});

it("returns null (delegation disabled) when no RS256 signing key is configured", async () => {
configure({ agentTokenSigningKey: undefined });
const token = await mintDelegatedToken({
subjectClaims: { sub: "agt_A" },
actorClaims: { sub: "agt_B" },
config: authConfig(),
});
expect(token).toBeNull();
});

it("a plain (non-delegated) token still verifies to sub — backward compatible", async () => {
const plain = await signAgentToken("agt_solo", authConfig());
expect(await verifyAgentToken(plain, authConfig())).toBe("agt_solo");
const claims = await verifyAgentTokenClaims(plain, authConfig());
expect(onBehalfOfAgent(claims!)).toBeUndefined();
});
});

// ─── the grant at POST /api/agents/token ─────────────────────────────

function tokenApp() {
const app = new Hono();
app.route("/", agentTokensRouter);
return app;
}

async function post(app: Hono, body: Record<string, unknown>) {
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, unknown> };
}

const EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";

describe("POST /api/agents/token — token-exchange grant", () => {
it("exchanges subject + actor tokens for a delegated token (actor acts for subject)", async () => {
const a = await createAgent("subject-A");
const b = await createAgent("actor-B");
const subjectToken = await signAgentToken(a.agent.id, authConfig());
const actorToken = await signAgentToken(b.agent.id, authConfig());

const { status, body } = await post(tokenApp(), {
grant_type: EXCHANGE,
subject_token: subjectToken,
actor_token: actorToken,
});

expect(status).toBe(200);
expect(body["token_type"]).toBe("Bearer");
expect(body["issued_token_type"]).toBe("urn:ietf:params:oauth:token-type:jwt");
const claims = await verifyAgentTokenClaims(body["access_token"] as string, authConfig());
expect(actingAgentOf(claims!)).toBe(b.agent.id); // enforced as the actor
expect(onBehalfOfAgent(claims!)).toBe(a.agent.id); // on behalf of the subject
});

it("rejects when the grant is disabled (unsupported_grant_type)", async () => {
configure({ agentTokenExchangeEnabled: false });
const a = await createAgent("A");
const b = await createAgent("B");
const { status, body } = await post(tokenApp(), {
grant_type: EXCHANGE,
subject_token: await signAgentToken(a.agent.id, authConfig()),
actor_token: await signAgentToken(b.agent.id, authConfig()),
});
expect(status).toBe(400);
expect(body["error"]).toBe("unsupported_grant_type");
});

it("requires both subject_token and actor_token (no impersonation)", async () => {
const a = await createAgent("A");
const { status, body } = await post(tokenApp(), {
grant_type: EXCHANGE,
subject_token: await signAgentToken(a.agent.id, authConfig()),
// actor_token omitted
});
expect(status).toBe(400);
expect(body["error"]).toBe("invalid_request");
});

it("rejects a forged/invalid token (invalid_grant)", async () => {
const a = await createAgent("A");
const { status, body } = await post(tokenApp(), {
grant_type: EXCHANGE,
subject_token: await signAgentToken(a.agent.id, authConfig()),
actor_token: "not.a.jwt",
});
expect(status).toBe(400);
expect(body["error"]).toBe("invalid_grant");
});

it("rejects when the actor is a revoked agent (invalid_grant)", async () => {
const a = await createAgent("A");
const b = await createAgent("B");
const subjectToken = await signAgentToken(a.agent.id, authConfig());
const actorToken = await signAgentToken(b.agent.id, authConfig());
await revokeAgent(b.agent.id);

const { status, body } = await post(tokenApp(), {
grant_type: EXCHANGE,
subject_token: subjectToken,
actor_token: actorToken,
});
expect(status).toBe(400);
expect(body["error"]).toBe("invalid_grant");
});

it("plain client_credentials still works (unchanged)", async () => {
const a = await createAgent("A");
const { status, body } = await post(tokenApp(), {
grant_type: "client_credentials",
client_id: a.agent.id,
client_secret: a.secret,
});
expect(status).toBe(200);
expect(body["token_type"]).toBe("Bearer");
expect(body["access_token"]).toBeTruthy();
});
});
15 changes: 15 additions & 0 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,14 @@ export const ConfigSchema = z.object({
/** Optional issuer (iss) claim minted into RS256 agent tokens so standard
* JWKS verifiers (AgentLens, Lore) can pin the issuer. */
agentTokenIssuer: z.string().optional(),
/** Enable the RFC-8693 token-exchange grant (#43) at POST /api/agents/token,
* which mints DELEGATED tokens ("agent B acting on behalf of A"). Off by
* default; requires AGENT_TOKEN_SIGNING_KEY (delegated tokens are RS256-only).
* When disabled the grant returns unsupported_grant_type. */
agentTokenExchangeEnabled: z
.union([z.boolean(), z.string()])
.transform((val) => (typeof val === "boolean" ? val : ["true", "1", "yes"].includes(val.toLowerCase())))
.default(false),

// ─── External SPIFFE/WIMSE workload identity (#41) ──────────────────
/** Accept SPIFFE JWT-SVIDs whose audience equals this value. REQUIRED to
Expand Down Expand Up @@ -402,6 +410,7 @@ const ENV_MAP: Record<string, keyof z.infer<typeof ConfigSchema>> = {
AGENT_TOKEN_VERIFY_KEYS: "agentTokenVerifyKeys",
AGENT_TOKEN_AUDIENCE: "agentTokenAudience",
AGENT_TOKEN_ISSUER: "agentTokenIssuer",
AGENT_TOKEN_EXCHANGE_ENABLED: "agentTokenExchangeEnabled",
SPIFFE_AUDIENCE: "spiffeAudience",
SPIFFE_TRUST_DOMAIN: "spiffeTrustDomain",
SPIFFE_JWKS_URL: "spiffeJwksUrl",
Expand Down Expand Up @@ -566,6 +575,12 @@ export function validateProductionConfig(config: Config): string[] {
"(audience scoping requires the RS256/JWKS path)",
);
}
if (config.agentTokenExchangeEnabled && !config.agentTokenSigningKey) {
warnings.push(
"AGENT_TOKEN_EXCHANGE_ENABLED has no effect without AGENT_TOKEN_SIGNING_KEY " +
"(delegated tokens are RS256-only); the token-exchange grant stays disabled",
);
}
if (config.agentTokenIssuer && !config.agentTokenSigningKey) {
warnings.push(
"AGENT_TOKEN_ISSUER has no effect without AGENT_TOKEN_SIGNING_KEY " +
Expand Down
Loading
Loading