From 38a7b8105d81c7cff9bb7138c7ac89da1e7aee16 Mon Sep 17 00:00:00 2001 From: Sean Hsieh Date: Thu, 13 Aug 2026 08:21:22 -0700 Subject: [PATCH] feat(agent-auth): approvalMethodHandlers for server-defined approval methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7.4 notes that custom approval methods "are defined, if at all, by extension profiles such as the Server-Defined Approval Profile (§10.10.3)". approvalMethods/resolveApprovalMethod already let a server name and select a custom method, but buildApprovalInfo had no way to actually build one -- any resolved method other than "ciba" fell through to the device_authorization branch unconditionally, silently mislabeling the stored ApprovalRequest. Adds approvalMethodHandlers: a registry of method name -> handler on AgentAuthOptions, consulted in buildApprovalInfo before the built-in branches. A resolved method with a matching handler is built by that handler and becomes the approval object in the API response; behavior for ciba/device_authorization and for any method without a registered handler is unchanged. Per §7's approval-object schema, method, expires_in, and interval are required on every method's response, including server-defined ones -- the handler is responsible for including them; the type itself stays Record rather than enforcing this, matching how the two built-in methods are already handled elsewhere in this file (plain object literals, no shared enforced base type). Widens ApprovalRequest.method past its two-value literal union (string & {}) to keep autocomplete for the two built-ins while allowing registered custom names -- the underlying column was always a plain string, this is a type-only change. Adds packages/agent-auth/src/__tests__/server-defined-approval-methods.test.ts: a registered handler wins over the built-ins, device_authorization is unaffected when no handler is registered, a resolved method with no matching handler still falls back to device_authorization, and the same behavior holds through request-capability on an already-active agent, not just at registration. --- .../server-defined-approval-methods.test.ts | 212 ++++++++++++++++++ packages/agent-auth/src/routes/_helpers.ts | 14 ++ packages/agent-auth/src/types.ts | 37 ++- 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 packages/agent-auth/src/__tests__/server-defined-approval-methods.test.ts diff --git a/packages/agent-auth/src/__tests__/server-defined-approval-methods.test.ts b/packages/agent-auth/src/__tests__/server-defined-approval-methods.test.ts new file mode 100644 index 000000000..beeb1a967 --- /dev/null +++ b/packages/agent-auth/src/__tests__/server-defined-approval-methods.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { getTestInstance } from "better-auth/test"; +import { + agentAuth, + agentAuthClientPlugin, + createAgentJWT, + createTestClient, + generateTestKeypair, + json, +} from "./helpers"; +import type { AgentAuthOptions } from "../types"; + +const TEST_CAPABILITIES = [ + { name: "check_balance", description: "Check account balance" }, + { name: "transfer", description: "Transfer money" }, +]; + +async function setup(pluginOpts: Partial) { + const t = await getTestInstance( + { + plugins: [ + agentAuth({ + providerName: "server-defined-approval-methods-test", + capabilities: TEST_CAPABILITIES, + modes: ["delegated"], + ...pluginOpts, + }), + ], + }, + { clientOptions: { plugins: [agentAuthClientPlugin()] } }, + ); + const client = createTestClient((req) => t.auth.handler(req)); + const { headers } = await t.signInWithTestUser(); + const sessionCookie = headers.get("cookie") ?? ""; + return { client, sessionCookie }; +} + +// ================================================================ +// A registered handler builds the approval response, in place of the +// built-in device_authorization/ciba branches. Server-Defined Approval +// Profile, §10.10.3: "This profile allows servers to expose approval +// methods beyond the core device_authorization and ciba set...The +// approval object carries whatever additional fields the client needs +// to facilitate the custom flow." +// ================================================================ + +describe("approvalMethodHandlers", () => { + it("a resolved custom method is built by its registered handler", async () => { + const { client, sessionCookie } = await setup({ + approvalMethods: ["device_authorization", "ciba", "org_review_queue"], + resolveApprovalMethod: async () => "org_review_queue", + approvalMethodHandlers: { + org_review_queue: async ({ agentId, capabilities }) => ({ + method: "org_review_queue", + agent_id: agentId, + pending_capabilities: capabilities, + expires_in: 180, + }), + }, + }); + + const hostKeypair = await generateTestKeypair(); + const createRes = await client.authedPost( + "/host/create", + { + name: "Test Host", + public_key: hostKeypair.publicKey, + default_capabilities: ["check_balance"], + }, + sessionCookie, + ); + const { hostId } = await json<{ hostId: string }>(createRes); + + const agentKeypair = await generateTestKeypair(); + // "transfer" is outside the host's default budget, so this stays + // pending and goes through the approval path. + const { agentId, body } = await client.registerAgentViaHost({ + hostKeypair, + agentKeypair, + hostId, + capabilities: ["check_balance", "transfer"], + }); + + const approval = body.approval as Record; + expect(approval.method).toBe("org_review_queue"); + expect(approval.agent_id).toBe(agentId); + // Registration passes the full requested set (resolved + pending), not + // just the pending delta -- unrelated to this change, matches register.ts's + // existing buildApprovalInfo call for both built-in methods already. + expect(approval.pending_capabilities).toEqual(["check_balance", "transfer"]); + expect(approval.expires_in).toBe(180); + // Built-in fields are absent -- the handler's own shape wins entirely. + expect(approval).not.toHaveProperty("device_code"); + expect(approval).not.toHaveProperty("user_code"); + }); + + it("built-in device_authorization is unaffected when no handler is registered for it", async () => { + const { client, sessionCookie } = await setup({ + approvalMethods: ["device_authorization", "ciba", "org_review_queue"], + resolveApprovalMethod: async () => "device_authorization", + approvalMethodHandlers: { + org_review_queue: async () => ({ method: "org_review_queue" }), + }, + }); + + const hostKeypair = await generateTestKeypair(); + const createRes = await client.authedPost( + "/host/create", + { + name: "Test Host", + public_key: hostKeypair.publicKey, + default_capabilities: ["check_balance"], + }, + sessionCookie, + ); + const { hostId } = await json<{ hostId: string }>(createRes); + + const agentKeypair = await generateTestKeypair(); + const { body } = await client.registerAgentViaHost({ + hostKeypair, + agentKeypair, + hostId, + capabilities: ["check_balance", "transfer"], + }); + + const approval = body.approval as Record; + expect(approval.method).toBe("device_authorization"); + expect(approval).toHaveProperty("device_code"); + expect(approval).toHaveProperty("user_code"); + }); + + it("a resolved method with no matching handler falls back to device_authorization", async () => { + const { client, sessionCookie } = await setup({ + approvalMethods: ["device_authorization", "ciba", "org_review_queue"], + resolveApprovalMethod: async () => "org_review_queue", + approvalMethodHandlers: { + // Registered under a different name than what resolves -- simulates + // a misconfiguration, proving the existing fallback still holds. + some_other_method: async () => ({ method: "some_other_method" }), + }, + }); + + const hostKeypair = await generateTestKeypair(); + const createRes = await client.authedPost( + "/host/create", + { + name: "Test Host", + public_key: hostKeypair.publicKey, + default_capabilities: ["check_balance"], + }, + sessionCookie, + ); + const { hostId } = await json<{ hostId: string }>(createRes); + + const agentKeypair = await generateTestKeypair(); + const { body } = await client.registerAgentViaHost({ + hostKeypair, + agentKeypair, + hostId, + capabilities: ["check_balance", "transfer"], + }); + + const approval = body.approval as Record; + expect(approval.method).toBe("device_authorization"); + }); + + it("request-capability for an already-active agent also builds via the registered handler", async () => { + const { client, sessionCookie } = await setup({ + approvalMethods: ["device_authorization", "org_review_queue"], + resolveApprovalMethod: async () => "org_review_queue", + approvalMethodHandlers: { + org_review_queue: async ({ capabilities }) => ({ + method: "org_review_queue", + pending_capabilities: capabilities, + }), + }, + }); + + const hostKeypair = await generateTestKeypair(); + const createRes = await client.authedPost( + "/host/create", + { + name: "Test Host", + public_key: hostKeypair.publicKey, + default_capabilities: ["check_balance"], + }, + sessionCookie, + ); + const { hostId } = await json<{ hostId: string }>(createRes); + + const agentKeypair = await generateTestKeypair(); + // Only default-budget capabilities -- agent comes up active + // immediately, no approval step at registration. + const { agentId } = await client.registerAgentViaHost({ + hostKeypair, + agentKeypair, + hostId, + capabilities: ["check_balance"], + }); + + const agentJWT = await createAgentJWT(agentKeypair.privateKey, agentId); + const res = await client.api("/agent/request-capability", { + method: "POST", + headers: { authorization: `Bearer ${agentJWT}` }, + body: JSON.stringify({ capabilities: ["transfer"] }), + }); + const body = await json<{ approval?: Record }>(res); + + expect(body.approval?.method).toBe("org_review_queue"); + expect(body.approval?.pending_capabilities).toEqual(["transfer"]); + }); +}); diff --git a/packages/agent-auth/src/routes/_helpers.ts b/packages/agent-auth/src/routes/_helpers.ts index 9997bdebf..423f3db2a 100644 --- a/packages/agent-auth/src/routes/_helpers.ts +++ b/packages/agent-auth/src/routes/_helpers.ts @@ -329,6 +329,20 @@ export async function buildApprovalInfo( const expiresAt = new Date(now.getTime() + expiresIn * 1000); const capabilitiesStr = context.capabilities.join(" ") || null; + // Server-Defined Approval Profile (§10.10.3): a handler registered + // under the resolved method name takes over entirely, before either + // built-in branch runs. + const customHandler = opts.approvalMethodHandlers?.[method]; + if (customHandler) { + return customHandler({ + agentId: context.agentId, + hostId: context.hostId, + userId: context.userId, + capabilities: context.capabilities, + expiresAt, + }); + } + if (method === "ciba" && context.userId) { const user = await internalAdapter.findUserById(context.userId); if (user) { diff --git a/packages/agent-auth/src/types.ts b/packages/agent-auth/src/types.ts index 7b6b8616b..a9dcada7c 100644 --- a/packages/agent-auth/src/types.ts +++ b/packages/agent-auth/src/types.ts @@ -215,10 +215,10 @@ export interface AgentCapabilityGrant { updatedAt: Date; } -/** Unified approval request for device authorization and CIBA flows. */ +/** Unified approval request for device authorization, CIBA, and server-defined extension methods (§7.4 / §10.10.3). */ export interface ApprovalRequest { id: string; - method: "device_authorization" | "ciba"; + method: "device_authorization" | "ciba" | (string & {}); agentId: string | null; hostId: string | null; userId: string | null; @@ -407,6 +407,39 @@ export interface AgentAuthOptions { preferredMethod?: string; supportedMethods: string[]; }) => string | Promise; + /** + * Handlers for the Server-Defined Approval Profile (§10.10.3), keyed + * by method name. §7.4 notes that custom approval methods "are + * defined, if at all, by extension profiles such as the Server-Defined + * Approval Profile (§10.10.3)" — this is that profile's server-side + * implementation surface. + * + * Consulted by `buildApprovalInfo` before the built-in `ciba`/ + * `device_authorization` branches: if `resolveApprovalMethod` + * resolves to a method with a matching handler here, that handler + * builds the approval response instead of falling back to + * `device_authorization`. The handler's return value becomes the + * `approval` object in the API response. + * + * Per §7's approval-object schema, `method`, `expires_in`, and + * `interval` are required on every method's approval object, + * including server-defined ones — the handler is responsible for + * including them. §10.10.3 leaves any additional fields to the + * profile: "The approval object carries whatever additional fields + * the client needs to facilitate the custom flow." + * + * @default {} — no custom methods, unchanged behavior. + */ + approvalMethodHandlers?: Record< + string, + (context: { + agentId: string; + hostId: string | null; + userId: string | null; + capabilities: string[]; + expiresAt: Date; + }) => Promise> + >; /** * Server JWKS URI for clients to verify server-signed responses (§6.1). */