From 7c002f40065e5f7107a7b1fd7ed6bf4f2a92a997 Mon Sep 17 00:00:00 2001 From: Amit Paz Date: Tue, 7 Jul 2026 15:55:30 +0300 Subject: [PATCH] feat(identity): RFC-8693 token exchange for delegated agent tokens (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1: DELEGATION. An actor agent B exchanges its own agent token (actor_token) + a subject agent's token (subject_token) at POST /api/agents/token for a short-lived DELEGATED token that says "B acting on behalf of A". Strict RFC-8693 §4.1 claim shape: sub = the on-behalf-of principal, the current actor in the top-level act.sub (chains nest inward). Every consumer reads the ACTOR via actingAgentOf() (act.sub ?? sub), so budgets (#13), MCP guardrails (#14), virtual-key binding and lens attribution all target the actor, while sub records who it acts for. A plain token has no act, so sub is the actor — existing tokens/verifiers are byte-for-byte unchanged. - lib/agent-tokens.ts: verifyAgentTokenClaims exposes {sub, act}; verifyAgentToken now returns the acting agent; actingAgentOf/onBehalfOfAgent helpers. - lib/token-exchange.ts (new): mint an RS256 delegated token (act chain, depth-capped), fail-safe. - routes/agent-tokens.ts: the token-exchange grant (opt-in + RS256-only; requires both subject_token + actor_token — no impersonation; actor must be a live agent; possession of both tokens is the authorization, may_act is Tier 2). - config.ts: AGENT_TOKEN_EXCHANGE_ENABLED (default off) + warn-if-partial. - docs/agent-identity.md: new section + config row + Future-work flip. Deferred (documented): may_act restriction (Tier 2), scope/audience narrowing (Tier 3). Must ship in lockstep with the AgentLens verifiedOnBehalfOf mirror. 15 new tests; full server suite green (bar the pre-existing rate-limiter flake). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CKkfVA3iY5G3Q43Mg4Xsu2 --- docs/agent-identity.md | 50 +++- .../src/__tests__/token-exchange.test.ts | 280 ++++++++++++++++++ packages/server/src/config.ts | 15 + packages/server/src/lib/agent-tokens.ts | 76 ++++- packages/server/src/lib/token-exchange.ts | 100 +++++++ packages/server/src/routes/agent-tokens.ts | 132 +++++++-- 6 files changed, 619 insertions(+), 34 deletions(-) create mode 100644 packages/server/src/__tests__/token-exchange.test.ts create mode 100644 packages/server/src/lib/token-exchange.ts diff --git a/docs/agent-identity.md b/docs/agent-identity.md index eb3d9fc..133d8b6 100644 --- a/docs/agent-identity.md +++ b/docs/agent-identity.md @@ -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= # the principal (on behalf of whom) +actor_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. @@ -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. | @@ -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). diff --git a/packages/server/src/__tests__/token-exchange.test.ts b/packages/server/src/__tests__/token-exchange.test.ts new file mode 100644 index 0000000..5b77699 --- /dev/null +++ b/packages/server/src/__tests__/token-exchange.test.ts @@ -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 = {}) { + 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) { + 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 }; +} + +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(); + }); +}); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index a7c7b33..a3312a0 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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 @@ -402,6 +410,7 @@ const ENV_MAP: Record> = { 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", @@ -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 " + diff --git a/packages/server/src/lib/agent-tokens.ts b/packages/server/src/lib/agent-tokens.ts index 542afad..57cb638 100644 --- a/packages/server/src/lib/agent-tokens.ts +++ b/packages/server/src/lib/agent-tokens.ts @@ -22,6 +22,43 @@ import { verifySpiffeSvid } from "./spiffe.js"; export const AGENT_TOKEN_TYP = "agent"; +/** + * RFC-8693 `act` (actor) claim. For a delegated token, `sub` is the principal + * (on whose behalf the call is made) and `act.sub` is the actor (the agent + * actually acting); a delegation chain nests older actors inward via `act.act`. + */ +export interface ActClaim { + sub?: string; + act?: ActClaim; + [k: string]: unknown; +} + +/** The verified identity claims we read off an agent token. */ +export interface AgentTokenClaims { + /** Token subject: the acting agent (plain token) or the principal (delegated). */ + sub: string; + /** Present only on a delegated (token-exchange) token. */ + act?: ActClaim; +} + +/** + * The agent that is ACTUALLY acting. RFC-8693 puts the current actor in the + * top-level `act.sub`; a plain (non-delegated) token has no `act`, so the actor + * IS `sub`. Every enforcement/attribution consumer wants this value — it is what + * {@link verifyAgentToken} returns, so existing callers keep working unchanged. + */ +export function actingAgentOf(claims: AgentTokenClaims): string { + return typeof claims.act?.sub === "string" ? claims.act.sub : claims.sub; +} + +/** + * The principal a delegated token acts ON BEHALF OF (the RFC-8693 `sub`), or + * undefined for a plain token. Used for attribution, never for enforcement. + */ +export function onBehalfOfAgent(claims: AgentTokenClaims): string | undefined { + return claims.act ? claims.sub : undefined; +} + /** * Mint a short-lived agent access token. sub = agt_* id; exp from config TTL. * When an RS256 signing key is configured (#40) the token is signed @@ -48,20 +85,31 @@ export async function signAgentToken(agentId: string, config: AuthConfig): Promi ); } +/** Narrow a verified JWT payload to {@link AgentTokenClaims} (sub required, act optional). */ +function toAgentClaims(payload: Record): AgentTokenClaims | null { + if (typeof payload.sub !== "string") return null; + const act = payload["act"]; + return { + sub: payload.sub, + ...(act && typeof act === "object" ? { act: act as ActClaim } : {}), + }; +} + /** - * Verify a Bearer token AS an agent token. Returns the agent id (sub) iff the - * signature is valid AND typ === "agent"; otherwise null — including for valid - * USER tokens, which must never cross over into the agent path. + * Verify a Bearer token AS an agent token and return its identity claims + * ({@link AgentTokenClaims}: `sub` + optional RFC-8693 `act`), iff the signature + * is valid AND typ === "agent"; otherwise null — including for valid USER tokens, + * which must never cross over into the agent path. * * The token's header `alg` selects the path: RS256-family tokens verify against * the local JWKS (#40, pinned to RS256 so an HS256/none token can't down-bid), * everything else uses the shared-secret HS256 verifier. Both paths re-check * `typ === "agent"`; the RS256 path also enforces the configured audience. */ -export async function verifyAgentToken( +export async function verifyAgentTokenClaims( token: string | undefined, config: AuthConfig, -): Promise { +): Promise { if (!token) return null; let alg: unknown; @@ -84,7 +132,7 @@ export async function verifyAgentToken( ...(audience ? { audience } : {}), }); if ((payload as Record).typ !== AGENT_TOKEN_TYP) return null; - return typeof payload.sub === "string" ? payload.sub : null; + return toAgentClaims(payload as Record); } catch { return null; } @@ -93,7 +141,21 @@ export async function verifyAgentToken( const claims = await verifyAccessToken(token, config); if (!claims) return null; if ((claims as Record).typ !== AGENT_TOKEN_TYP) return null; - return typeof claims.sub === "string" ? claims.sub : null; + return toAgentClaims(claims as Record); +} + +/** + * Verify a Bearer token AS an agent token. Returns the ACTING agent id (the + * top-level `act.sub` for a delegated token, else `sub`) iff valid, else null. + * Every enforcement consumer resolves through here, so a delegated token is + * enforced against the actor — not the on-behalf-of principal. + */ +export async function verifyAgentToken( + token: string | undefined, + config: AuthConfig, +): Promise { + const claims = await verifyAgentTokenClaims(token, config); + return claims ? actingAgentOf(claims) : null; } /** diff --git a/packages/server/src/lib/token-exchange.ts b/packages/server/src/lib/token-exchange.ts new file mode 100644 index 0000000..4804ed0 --- /dev/null +++ b/packages/server/src/lib/token-exchange.ts @@ -0,0 +1,100 @@ +// @agentkitai/agentgate-server — RFC-8693 token exchange (delegated agent tokens, #43). +// +// Tier 1: DELEGATION only. An actor agent (B) that holds both its own agent +// token AND a subject agent's token (A's, given to it by A) exchanges the pair +// for a short-lived DELEGATED token that says "B is acting on behalf of A". +// +// The result follows RFC-8693 §4.1 strictly: `sub` = the on-behalf-of principal +// (A), and the current actor (B) is in the top-level `act.sub`, with any prior +// delegation nested inward. Every AgentGate/AgentLens consumer reads the ACTOR +// via actingAgentOf() (act.sub ?? sub), so enforcement + attribution target B +// (the acting agent) while `sub` records who B acts for. +// +// Opt-in and RS256-only: delegated tokens are always asymmetrically signed +// (verifiable via the published JWKS, no shared secret) and require +// AGENT_TOKEN_SIGNING_KEY (#40) + AGENT_TOKEN_EXCHANGE_ENABLED. This module owns +// the minting only; the grant is wired in routes/agent-tokens.ts. + +import { SignJWT } from "jose"; +import type { AuthConfig } from "@agentkitai/auth"; + +import { AGENT_TOKEN_TYP, actingAgentOf, type ActClaim, type AgentTokenClaims } from "./agent-tokens.js"; +import { getActiveSigningKey } from "./agent-token-keys.js"; + +/** Cap on delegation-chain depth so a crafted `act` chain can't nest unbounded. */ +export const MAX_DELEGATION_DEPTH = 8; + +/** Count the delegation depth of an `act` chain (0 for a plain token). */ +export function delegationDepth(act: ActClaim | undefined): number { + let n = 0; + let cur = act; + while (cur && n <= MAX_DELEGATION_DEPTH + 1) { + n += 1; + cur = cur.act; + } + return n; +} + +/** + * Build the RFC-8693 `act` chain + `sub` for a new delegation of `actorClaims` + * acting on behalf of `subjectClaims`. Pure + unit-testable (no signing). + * - `sub` = subjectClaims.sub (the on-behalf-of principal, preserved through chains) + * - `act` = { sub: , act?: } (current actor on top) + * Returns null if the actor can't be resolved or the resulting chain is too deep. + */ +export function buildDelegatedClaims( + subjectClaims: AgentTokenClaims, + actorClaims: AgentTokenClaims, +): { sub: string; act: ActClaim } | null { + const actor = actingAgentOf(actorClaims); + if (!actor) return null; + const act: ActClaim = { sub: actor, ...(subjectClaims.act ? { act: subjectClaims.act } : {}) }; + // Depth of the NEW chain = the subject's existing depth + this actor. + if (delegationDepth(act) > MAX_DELEGATION_DEPTH) return null; + return { sub: subjectClaims.sub, act }; +} + +export interface MintDelegatedOptions { + /** Verified claims of the subject_token (the principal / on-behalf-of party). */ + subjectClaims: AgentTokenClaims; + /** Verified claims of the actor_token (the agent that will wield the token). */ + actorClaims: AgentTokenClaims; + /** Optional narrowed audience for the delegated token (RFC-8693 `audience`/`resource`). */ + audience?: string | undefined; + config: AuthConfig; +} + +/** + * Mint a delegated RS256 agent token. Returns the compact JWT, or null when + * delegation is unavailable (no RS256 signing key) or the claims can't be built. + * Fail-safe: never throws — the caller maps null to an OAuth error. + */ +export async function mintDelegatedToken(opts: MintDelegatedOptions): Promise { + const signer = await getActiveSigningKey(); + if (!signer) return null; // RS256-only: no asymmetric signer ⇒ delegation disabled + const built = buildDelegatedClaims(opts.subjectClaims, opts.actorClaims); + if (!built) return null; + try { + // Mirror signAgentToken's inert placeholder claims (tid/role/email) so the + // delegated token satisfies AccessTokenClaims and carries typ:"agent". + const builder = new SignJWT({ + tid: "default", + role: "viewer", + email: "", + typ: AGENT_TOKEN_TYP, + act: built.act, + }) + .setProtectedHeader({ alg: "RS256", kid: signer.kid, typ: "JWT" }) + .setSubject(built.sub) + .setIssuedAt() + .setExpirationTime(`${opts.config.jwt.accessTokenTtlSeconds}s`); + // A requested audience narrows the token; otherwise fall back to the signer's + // configured audience (if any). Issuer always comes from the signer. + const audience = opts.audience ?? signer.audience; + if (audience) builder.setAudience(audience); + if (signer.issuer) builder.setIssuer(signer.issuer); + return await builder.sign(signer.key); + } catch { + return null; + } +} diff --git a/packages/server/src/routes/agent-tokens.ts b/packages/server/src/routes/agent-tokens.ts index 5407a08..9883780 100644 --- a/packages/server/src/routes/agent-tokens.ts +++ b/packages/server/src/routes/agent-tokens.ts @@ -1,25 +1,40 @@ // @agentkitai/agentgate-server — Agent OAuth2 token endpoint (agent-identity spine). // -// POST /api/agents/token — RFC 6749 client_credentials grant. An agent presents -// its agt_*/ags_* credential (JSON body or HTTP Basic) and receives a -// short-lived Bearer JWT to use as X-Agent-Token. PUBLIC: mounted before the -// user-auth middleware — the agent's own credential is the authentication. +// POST /api/agents/token supports two grants: +// - client_credentials (RFC 6749): an agent presents its agt_*/ags_* credential +// and receives a short-lived Bearer JWT to use as X-Agent-Token. +// - urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693, #43): an actor +// agent presents its own agent token (actor_token) AND a subject agent's token +// (subject_token) and receives a DELEGATED token ("actor acting on behalf of +// subject"). Opt-in (AGENT_TOKEN_EXCHANGE_ENABLED) + RS256-only. +// PUBLIC: mounted before the user-auth middleware — the presented credential/token +// IS the authentication. import { Hono, type Context } from "hono"; -import { verifyAgentCredential } from "../lib/agents.js"; -import { signAgentToken } from "../lib/agent-tokens.js"; +import { getAgentIfActive, verifyAgentCredential } from "../lib/agents.js"; +import { actingAgentOf, signAgentToken, verifyAgentTokenClaims } from "../lib/agent-tokens.js"; +import { mintDelegatedToken } from "../lib/token-exchange.js"; +import { getActiveSigningKey } from "../lib/agent-token-keys.js"; import { getAuthConfig } from "../lib/auth-config.js"; import { getConfig } from "../config.js"; const router = new Hono(); -/** Pull client_id/client_secret/grant_type from HTTP Basic (preferred) or body. */ -async function parseCredentials(c: Context): Promise<{ +const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; +/** RFC-8693 issued-token-type for a self-contained JWT. */ +const JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"; + +interface TokenParams { + grantType?: string; clientId?: string; clientSecret?: string; - grantType?: string; -}> { + subjectToken?: string; + actorToken?: string; +} + +/** Pull grant + client_credentials + token-exchange params from HTTP Basic or the body. */ +async function parseParams(c: Context): Promise { let clientId: string | undefined; let clientSecret: string | undefined; @@ -33,22 +48,83 @@ async function parseCredentials(c: Context): Promise<{ } } - let grantType: string | undefined; + const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); const ctype = c.req.header("content-type") ?? ""; - if (ctype.includes("application/json")) { - const body = (await c.req.json().catch(() => ({}))) as Record; - grantType = typeof body.grant_type === "string" ? body.grant_type : undefined; - clientId ??= typeof body.client_id === "string" ? body.client_id : undefined; - clientSecret ??= typeof body.client_secret === "string" ? body.client_secret : undefined; - } else { - // RFC 6749 norm: application/x-www-form-urlencoded. - const form = await c.req.parseBody().catch(() => ({}) as Record); - grantType = typeof form.grant_type === "string" ? form.grant_type : undefined; - clientId ??= typeof form.client_id === "string" ? form.client_id : undefined; - clientSecret ??= typeof form.client_secret === "string" ? form.client_secret : undefined; + const body = ctype.includes("application/json") + ? ((await c.req.json().catch(() => ({}))) as Record) + : ((await c.req.parseBody().catch(() => ({}))) as Record); + + clientId ??= str(body["client_id"]); + clientSecret ??= str(body["client_secret"]); + return { + grantType: str(body["grant_type"]), + clientId, + clientSecret, + subjectToken: str(body["subject_token"]), + actorToken: str(body["actor_token"]), + }; +} + +/** + * RFC-8693 token exchange (Tier 1: delegation only). Requires BOTH subject_token + * (the principal) and actor_token (the actor) — an omitted actor_token would be + * impersonation, which is deliberately NOT supported (a delegated token always + * leaves an `act` trace). Possession of a valid actor_token + subject_token is + * the authorization (a may_act restriction is Tier 2). The result is enforced + + * attributed to the ACTOR, with the on-behalf-of principal in `sub`. + */ +async function handleTokenExchange(c: Context, params: TokenParams): Promise { + const cfg = getConfig(); + // Off unless explicitly enabled AND an RS256 signer exists (delegated tokens + // are RS256-only). Same response as an unknown grant so we leak no capability. + if (!cfg.agentTokenExchangeEnabled || !(await getActiveSigningKey())) { + return c.json({ error: "unsupported_grant_type" }, 400); + } + if (!params.subjectToken || !params.actorToken) { + return c.json( + { + error: "invalid_request", + error_description: "token exchange requires both subject_token and actor_token", + }, + 400, + ); } - return { clientId, clientSecret, grantType }; + const authConfig = getAuthConfig(); + const subjectClaims = await verifyAgentTokenClaims(params.subjectToken, authConfig); + const actorClaims = await verifyAgentTokenClaims(params.actorToken, authConfig); + if (!subjectClaims || !actorClaims) { + // One of the presented tokens is not a valid agent token. + return c.json( + { error: "invalid_grant", error_description: "invalid subject_token or actor_token" }, + 400, + ); + } + + // The actor must be a live, non-revoked agent at exchange time (mirrors the + // use-time liveness re-check on the resolve path). + const actorId = actingAgentOf(actorClaims); + if (!(await getAgentIfActive(actorId))) { + return c.json( + { error: "invalid_grant", error_description: "actor is not an active agent" }, + 400, + ); + } + + const token = await mintDelegatedToken({ subjectClaims, actorClaims, config: authConfig }); + if (!token) { + // Unreachable in practice (signer checked above); guards a too-deep chain. + return c.json( + { error: "invalid_request", error_description: "could not mint delegated token" }, + 400, + ); + } + return c.json({ + access_token: token, + issued_token_type: JWT_TOKEN_TYPE, + token_type: "Bearer", + expires_in: cfg.jwtAccessTtl, + }); } router.post("/", async (c) => { @@ -62,12 +138,16 @@ router.post("/", async (c) => { ); } - const { clientId, clientSecret, grantType } = await parseCredentials(c); - if (grantType !== "client_credentials") { + const params = await parseParams(c); + + if (params.grantType === TOKEN_EXCHANGE_GRANT) { + return handleTokenExchange(c, params); + } + if (params.grantType !== "client_credentials") { return c.json({ error: "unsupported_grant_type" }, 400); } - const agent = await verifyAgentCredential(clientId, clientSecret); + const agent = await verifyAgentCredential(params.clientId, params.clientSecret); if (!agent) { // Identical response for unknown id vs bad/revoked secret — no enumeration. return c.json({ error: "invalid_client" }, 401);