From d823d8d63ac469813815a9f081ab58efd08ef6be Mon Sep 17 00:00:00 2001 From: ptahdunbar Date: Sun, 12 Jul 2026 12:01:29 -0400 Subject: [PATCH 1/4] feat(core): export A2A verification + owner-context seeding, add actionRouteAuth adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspaces that accept A2A-authenticated callers on the direct HTTP action routes currently have to reach into core internals — hardcoding the private __agentNativeOwnerContext key string and reimplementing HS256 verification (losing org-level fallback secrets). All additive: - export verifyA2AToken + A2ATokenPayload from @agent-native/core/a2a (exact existing semantics incl. org-domain fallback secrets; event param now optional) - export AGENT_RUN_OWNER_CONTEXT_KEY, seedAgentRunOwnerContext, and AgentRunOwnerContext from the server barrel - new actionRouteAuth.resolveCaller adapter on mountActionRoutes / createAgentChatPlugin: runs before the getSession chain, seeds the resolved caller so nested agent runs observe it; null/throw defers to existing auth 9 new tests (verifyA2AToken semantics, adapter precedence/fallthrough); changeset included (minor). --- .changeset/a2a-action-route-auth.md | 20 +++ packages/core/src/a2a/index.ts | 3 +- packages/core/src/a2a/server.spec.ts | 104 +++++++++++++ packages/core/src/a2a/server.ts | 26 +++- .../core/src/server/action-routes.spec.ts | 138 ++++++++++++++++++ packages/core/src/server/action-routes.ts | 53 ++++++- packages/core/src/server/agent-chat-plugin.ts | 1 + .../src/server/agent-chat/plugin-options.ts | 9 ++ packages/core/src/server/index.ts | 6 + 9 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 .changeset/a2a-action-route-auth.md diff --git a/.changeset/a2a-action-route-auth.md b/.changeset/a2a-action-route-auth.md new file mode 100644 index 0000000000..c9b58164b8 --- /dev/null +++ b/.changeset/a2a-action-route-auth.md @@ -0,0 +1,20 @@ +--- +"@agent-native/core": minor +--- + +Export first-class A2A auth primitives for the HTTP action route so workspaces +stop reaching into core internals: + +- `verifyA2AToken` (and `A2ATokenPayload`) from `@agent-native/core/a2a` — the + same verifier the `/_agent-native/a2a` endpoint uses, including org-level + fallback secrets. Apps no longer need to reimplement a partial HS256 verifier. +- `AGENT_RUN_OWNER_CONTEXT_KEY`, `seedAgentRunOwnerContext`, and + `AgentRunOwnerContext` from `@agent-native/core/server` — a typed contract for + pre-seeding the resolved caller, replacing the hardcoded context-key string. +- A new `actionRouteAuth` option on `createAgentChatPlugin` (and `ActionRouteAuthAdapter` + / `actionRouteAuth` on `mountActionRoutes`). Its `resolveCaller` runs before + the `getSession` chain on `/_agent-native/actions/*`, letting apps accept A2A + JWTs declaratively instead of intercepting Nitro's `request` hook. Returning + `null` (or throwing) defers to the existing framework auth chain. + +All additive — existing callers are unaffected. diff --git a/packages/core/src/a2a/index.ts b/packages/core/src/a2a/index.ts index 5f74e4b427..019935e5f1 100644 --- a/packages/core/src/a2a/index.ts +++ b/packages/core/src/a2a/index.ts @@ -1,5 +1,6 @@ // Server (H3/Nitro) -export { mountA2A } from "./server.js"; +export { mountA2A, verifyA2AToken } from "./server.js"; +export type { A2ATokenPayload } from "./server.js"; export { generateAgentCard } from "./agent-card.js"; // Client diff --git a/packages/core/src/a2a/server.spec.ts b/packages/core/src/a2a/server.spec.ts index fbfe2495ed..9b0cef2264 100644 --- a/packages/core/src/a2a/server.spec.ts +++ b/packages/core/src/a2a/server.spec.ts @@ -299,6 +299,110 @@ describe("mountA2A auth", () => { }); }); +describe("verifyA2AToken (exported)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + getA2ASecretByDomainMock.mockReset(); + process.env = { ...originalEnv, NODE_ENV: "production" }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + async function signToken( + secret: string, + claims: Record, + exp: string | number = "15m", + ): Promise { + return new jose.SignJWT(claims) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(exp) + .sign(new TextEncoder().encode(secret)); + } + + it("verifies a token signed with the shared A2A_SECRET", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("shared-global-secret", { + sub: "alice@builder.io", + }); + + // Event is optional: no audience claim, no org lookup needed here. + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: "alice@builder.io", orgDomain: null }); + }); + + it("falls back to the org-level secret via org_domain (shared secret absent)", async () => { + delete process.env.A2A_SECRET; + getA2ASecretByDomainMock.mockResolvedValueOnce("org-a2a-secret"); + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("org-a2a-secret", { + sub: "bob@builder.io", + org_domain: "builder.io", + }); + + const result = await verifyA2AToken(token); + + expect(getA2ASecretByDomainMock).toHaveBeenCalledWith("builder.io"); + expect(result).toEqual({ + email: "bob@builder.io", + orgDomain: "builder.io", + }); + }); + + it("rejects a token whose signature matches no candidate secret", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + getA2ASecretByDomainMock.mockResolvedValueOnce(undefined); + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("some-other-secret", { + sub: "mallory@builder.io", + org_domain: "builder.io", + }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); + + it("rejects an expired token", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken( + "shared-global-secret", + { sub: "alice@builder.io" }, + Math.floor(Date.now() / 1000) - 60, + ); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); + + it("returns null identity when no secret is configured", async () => { + delete process.env.A2A_SECRET; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("anything", { sub: "alice@builder.io" }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); + + it("does not throw on a malformed token", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + const { verifyA2AToken } = await import("./server.js"); + + const result = await verifyA2AToken("not-a-jwt"); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); +}); + async function mountedAgentCardHandler( config: A2AConfig, routePrefix?: string, diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index 0b4ad9894c..a5acd2aae8 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -38,10 +38,11 @@ function warnA2AUnauthOnce(): void { } /** - * Verify an inbound A2A JWT signed with the shared A2A_SECRET. - * Returns the caller's email (from `sub` claim) if valid, null otherwise. + * Result of verifying an inbound A2A JWT. `email` is the caller identity from + * the token's `sub` claim (null when verification fails), `orgDomain` mirrors + * the verified `org_domain` claim when present. */ -interface A2ATokenPayload { +export interface A2ATokenPayload { email: string | null; orgDomain: string | null; } @@ -80,9 +81,24 @@ function expectedJwtAudience(event: any | undefined): string | undefined { return undefined; } -async function verifyA2AToken( +/** + * Verify an inbound A2A bearer token (HS256) exactly as the + * `/_agent-native/a2a` endpoint does: it peeks at the unverified `org_domain` + * claim to build an ordered candidate-secret set (`process.env.A2A_SECRET` + * plus any org-level secret for that domain), then verifies the JWT — checking + * `aud`/`iss` when the token carries them and `exp` always. Returns the + * caller's email (`sub`) and org domain on success, or `{ email: null, + * orgDomain: null }` on any failure (malformed, bad signature, expired, or no + * secret configured), never throwing. + * + * Exported so workspaces can accept A2A callers on the HTTP action route with + * the same routine — including org-level fallback secrets — instead of + * reimplementing a partial verifier. Pass the H3 `event` to enable org-domain → + * org-secret lookup and audience derivation; it is optional. + */ +export async function verifyA2AToken( token: string, - event: any | undefined, + event?: any, ): Promise { // Step 1: Peek at JWT claims WITHOUT verification to get org_domain. // This is safe because we only use org_domain to look up the secret, diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index ad4a64fae8..a10838921c 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -777,4 +777,142 @@ describe("mountActionRoutes", () => { expect(result).toEqual({ ok: true }); expect(actions["share-resource"].run).toHaveBeenCalledTimes(1); }); + + // --------------------------------------------------------------------- + // actionRouteAuth adapter (runs before getOwnerFromEvent / getSession) + // --------------------------------------------------------------------- + + it("runs the action scoped to actionRouteAuth.resolveCaller and skips getOwnerFromEvent", async () => { + const { mountActionRoutes } = await import("./action-routes.js"); + const { AGENT_RUN_OWNER_CONTEXT_KEY } = + await import("./agent-run-context.js"); + const { getRequestUserEmail, getRequestUserName } = + await import("./request-context.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const getOwnerFromEvent = vi.fn(async () => "session-user@example.com"); + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = { + ctx, + requestUserEmail: getRequestUserEmail(), + requestUserName: getRequestUserName(), + }; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent, + actionRouteAuth: { + resolveCaller: async () => ({ + owner: "a2a-caller@example.com", + anonymous: false, + name: "A2A Caller", + }), + }, + }); + + const event = { + _method: "POST", + _headers: {}, + context: {} as Record, + req: { json: async () => ({}) }, + }; + const result = await mounted[0].handler(event); + + expect(result).toEqual({ ok: true }); + expect(received.ctx.userEmail).toBe("a2a-caller@example.com"); + expect(received.requestUserEmail).toBe("a2a-caller@example.com"); + expect(received.requestUserName).toBe("A2A Caller"); + // The framework session chain is never consulted when the adapter resolves. + expect(getOwnerFromEvent).not.toHaveBeenCalled(); + // The resolved caller is seeded so nested agent runs see the same identity. + expect(event.context[AGENT_RUN_OWNER_CONTEXT_KEY]).toEqual({ + owner: "a2a-caller@example.com", + anonymous: false, + name: "A2A Caller", + }); + }); + + it("falls through to getOwnerFromEvent when resolveCaller returns null", async () => { + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const getOwnerFromEvent = vi.fn(async () => "session-user@example.com"); + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = ctx; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent, + actionRouteAuth: { resolveCaller: async () => null }, + }); + + await mounted[0].handler({ + _method: "POST", + _headers: {}, + context: {}, + req: { json: async () => ({}) }, + }); + + expect(getOwnerFromEvent).toHaveBeenCalledTimes(1); + expect(received.userEmail).toBe("session-user@example.com"); + }); + + it("defers to getOwnerFromEvent when resolveCaller throws", async () => { + const { mountActionRoutes } = await import("./action-routes.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const getOwnerFromEvent = vi.fn(async () => "session-user@example.com"); + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = ctx; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent, + actionRouteAuth: { + resolveCaller: async () => { + throw new Error("verifier exploded"); + }, + }, + }); + + await mounted[0].handler({ + _method: "POST", + _headers: {}, + context: {}, + req: { json: async () => ({}) }, + }); + + expect(getOwnerFromEvent).toHaveBeenCalledTimes(1); + expect(received.userEmail).toBe("session-user@example.com"); + }); }); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index 4a1985fe89..d16f960c85 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -18,6 +18,10 @@ import { shouldAllowMcpEmbedCredentials, } from "../shared/mcp-embed-headers.js"; import { notifyActionChange } from "./action-change.js"; +import { + seedAgentRunOwnerContext, + type AgentRunOwnerContext, +} from "./agent-run-context.js"; import { getAllowedCorsOrigin as resolveAllowedCorsOrigin, readCorsAllowedOrigins, @@ -164,6 +168,26 @@ function handleOptionsRequest(event: any): string { return ""; } +/** + * Declarative auth adapter for the HTTP action route. Its `resolveCaller` runs + * BEFORE the framework's `getOwnerFromEvent` / `getSession` chain, letting an + * app accept caller identities `getSession` doesn't understand (e.g. an A2A + * JWT) without reaching into request context from a Nitro `request` hook. + * + * Scoped to `/_agent-native/actions/*` only — it does not affect other routes. + */ +export interface ActionRouteAuthAdapter { + /** + * Resolve a caller from the raw event before the cookie/bearer chain. Return + * the resolved owner context to run the action scoped to that caller, or + * `null` to defer to `getOwnerFromEvent` / `getSession`. A thrown error is + * treated the same as returning `null` (the framework chain still runs). + */ + resolveCaller?: ( + event: any, + ) => AgentRunOwnerContext | null | Promise; +} + export interface MountActionRoutesOptions { /** Resolve owner email from the H3 event (for data scoping). */ getOwnerFromEvent?: (event: any) => string | Promise; @@ -173,6 +197,12 @@ export interface MountActionRoutesOptions { ) => string | undefined | Promise; /** Resolve org ID from the H3 event (for org scoping). */ resolveOrgId?: (event: any) => string | null | Promise; + /** + * Optional caller resolver that runs before the `getOwnerFromEvent` / + * `getSession` chain. Lets apps accept A2A JWTs (or other bearer schemes) on + * the action route declaratively. See {@link ActionRouteAuthAdapter}. + */ + actionRouteAuth?: ActionRouteAuthAdapter; } function isAuthResolutionFailure(error: unknown): boolean { @@ -259,7 +289,28 @@ export function mountActionRoutes( // Resolve auth context for per-request scoping let userEmail: string | undefined; let userName: string | undefined; - if (options?.getOwnerFromEvent) { + // An app-supplied auth adapter runs first: it can accept caller + // identities the framework's getSession chain doesn't understand (e.g. + // an A2A JWT). A resolved caller is seeded onto the event context so any + // downstream resolveAgentRunOwnerContext (nested agent runs) sees the + // same identity; a null return (or a throw) falls through to + // getOwnerFromEvent below. The adapter is only consulted for the action + // route, so it can't affect other surfaces. + let resolvedByAdapter = false; + if (options?.actionRouteAuth?.resolveCaller) { + try { + const caller = await options.actionRouteAuth.resolveCaller(event); + if (caller) { + seedAgentRunOwnerContext(event, caller); + userEmail = caller.owner; + userName = caller.name; + resolvedByAdapter = true; + } + } catch { + // Adapter failure defers to the framework auth chain below. + } + } + if (!resolvedByAdapter && options?.getOwnerFromEvent) { try { userEmail = await options.getOwnerFromEvent(event); userName = options?.getUserNameFromEvent diff --git a/packages/core/src/server/agent-chat-plugin.ts b/packages/core/src/server/agent-chat-plugin.ts index b3327d8ad2..4d30a91228 100644 --- a/packages/core/src/server/agent-chat-plugin.ts +++ b/packages/core/src/server/agent-chat-plugin.ts @@ -1716,6 +1716,7 @@ export function createAgentChatPlugin( getOwnerFromEvent, getUserNameFromEvent, resolveOrgId: options?.resolveOrgId, + actionRouteAuth: options?.actionRouteAuth, }); } diff --git a/packages/core/src/server/agent-chat/plugin-options.ts b/packages/core/src/server/agent-chat/plugin-options.ts index 22b069941e..188fa91a16 100644 --- a/packages/core/src/server/agent-chat/plugin-options.ts +++ b/packages/core/src/server/agent-chat/plugin-options.ts @@ -106,6 +106,15 @@ export interface AgentChatPluginOptions { * agent mutations. */ anonymousReadOnly?: boolean; + /** + * Optional auth adapter for the HTTP action route + * (`/_agent-native/actions/*`). Its `resolveCaller` runs before the + * cookie/bearer `getSession` chain, letting an app accept caller identities + * `getSession` doesn't understand (e.g. an A2A JWT verified with + * `verifyA2AToken`) declaratively, instead of pre-seeding request context + * from a Nitro `request` hook. Returning `null` defers to the normal chain. + */ + actionRouteAuth?: import("../action-routes.js").ActionRouteAuthAdapter; /** * Optional callback to append template-specific context to the system * prompt on each request. Runs after AGENTS.md / skills / memory are diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index c4c4617b8c..02a13119b5 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -320,7 +320,13 @@ export { export { mountActionRoutes, type MountActionRoutesOptions, + type ActionRouteAuthAdapter, } from "./action-routes.js"; +export { + AGENT_RUN_OWNER_CONTEXT_KEY, + seedAgentRunOwnerContext, + type AgentRunOwnerContext, +} from "./agent-run-context.js"; export { runWithRequestContext, hasRequestContext, From 5bbf2060b09a45f2fe13f91760687a98d5ea5a19 Mon Sep 17 00:00:00 2001 From: ptahdunbar Date: Sun, 12 Jul 2026 12:35:24 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(core):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20fail=20closed=20on=20underivable=20audience,=20org=20fallbac?= =?UTF-8?q?k=20for=20adapter=20callers,=20adapter=20throw=20=3D=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verifyA2AToken: a token carrying aud is rejected when the receiver cannot derive its own expected audience (previously verified signature-only — a correctly-signed token minted for another service was accepted when APP_URL/URL were unset). Tokens without aud keep existing behavior - action route adapter path: adapter-resolved callers now get the same owner-based org fallback as resolveAgentRunOrgId, so A2A-authenticated action calls carry orgId instead of writing org-scoped rows with NULL - ActionRouteAuthAdapter contract: resolveCaller returning null defers to the normal session chain; THROWING now hard-rejects 401 (an expired/forged bearer must not fall through to a same-origin session cookie); docs updated - +5 tests covering the aud matrix, org fallback, and null-vs-throw semantics --- .changeset/a2a-action-route-auth.md | 5 +- packages/core/src/a2a/server.spec.ts | 68 ++++++++++++++ packages/core/src/a2a/server.ts | 9 +- .../core/src/server/action-routes.spec.ts | 94 +++++++++++++++++-- packages/core/src/server/action-routes.ts | 75 +++++++++++---- .../src/server/agent-chat/plugin-options.ts | 6 +- 6 files changed, 225 insertions(+), 32 deletions(-) diff --git a/.changeset/a2a-action-route-auth.md b/.changeset/a2a-action-route-auth.md index c9b58164b8..cf3547ca61 100644 --- a/.changeset/a2a-action-route-auth.md +++ b/.changeset/a2a-action-route-auth.md @@ -15,6 +15,9 @@ stop reaching into core internals: / `actionRouteAuth` on `mountActionRoutes`). Its `resolveCaller` runs before the `getSession` chain on `/_agent-native/actions/*`, letting apps accept A2A JWTs declaratively instead of intercepting Nitro's `request` hook. Returning - `null` (or throwing) defers to the existing framework auth chain. + `null` defers to the existing framework auth chain; throwing hard-rejects the + request with a 401 so an invalid credential can't fall through to a + same-origin session cookie. A resolved caller's org is derived with the same + owner-based fallback the framework chain uses, so A2A writes stay org-scoped. All additive — existing callers are unaffected. diff --git a/packages/core/src/a2a/server.spec.ts b/packages/core/src/a2a/server.spec.ts index 9b0cef2264..51c016276e 100644 --- a/packages/core/src/a2a/server.spec.ts +++ b/packages/core/src/a2a/server.spec.ts @@ -401,6 +401,74 @@ describe("verifyA2AToken (exported)", () => { expect(result).toEqual({ email: null, orgDomain: null }); }); + + it("rejects a correctly-signed token whose aud targets another service (no derivable audience)", async () => { + // The signature is valid, but the token was minted for a different + // receiver. With no APP_URL/URL and no request event, this receiver can't + // derive its own audience — it must fail closed rather than accept a + // foreign-audience token just because the shared secret matches. + process.env.A2A_SECRET = "shared-global-secret"; + delete process.env.APP_URL; + delete process.env.URL; + delete process.env.DEPLOY_URL; + delete process.env.BETTER_AUTH_URL; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("shared-global-secret", { + sub: "mallory@builder.io", + aud: "https://attacker.example", + }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); + + it("still accepts a token WITHOUT an aud claim when no audience can be derived", async () => { + // Backward-compat: tokens minted before the audience claim shipped (and + // internal callers that don't set one) carry no `aud`, so there is nothing + // to check — the secret + exp checks still gate them. + process.env.A2A_SECRET = "shared-global-secret"; + delete process.env.APP_URL; + delete process.env.URL; + delete process.env.DEPLOY_URL; + delete process.env.BETTER_AUTH_URL; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("shared-global-secret", { + sub: "alice@builder.io", + }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: "alice@builder.io", orgDomain: null }); + }); + + it("accepts a token whose aud matches the receiver's derived audience", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + process.env.APP_URL = "https://receiver.example"; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("shared-global-secret", { + sub: "alice@builder.io", + aud: "https://receiver.example", + }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: "alice@builder.io", orgDomain: null }); + }); + + it("rejects a token whose aud does not match the receiver's derived audience", async () => { + process.env.A2A_SECRET = "shared-global-secret"; + process.env.APP_URL = "https://receiver.example"; + const { verifyA2AToken } = await import("./server.js"); + const token = await signToken("shared-global-secret", { + sub: "mallory@builder.io", + aud: "https://attacker.example", + }); + + const result = await verifyA2AToken(token); + + expect(result).toEqual({ email: null, orgDomain: null }); + }); }); async function mountedAgentCardHandler( diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index a5acd2aae8..a90d03fb72 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -148,8 +148,15 @@ export async function verifyA2AToken( try { const verifyOptions: jose.JWTVerifyOptions = {}; if (unverifiedPayload && typeof unverifiedPayload.aud !== "undefined") { + // Fail closed: the token was minted for a specific audience, but this + // receiver can't derive its own expected audience (no APP_URL/URL and no + // usable request host). Accepting here would let a correctly-signed token + // whose `aud` targets ANOTHER service verify against a shared secret. A + // token that self-declares an audience must be checked against ours, so + // when we have nothing to check it against we reject rather than skip. const aud = expectedJwtAudience(event); - if (aud) verifyOptions.audience = aud; + if (!aud) return { email: null, orgDomain: null }; + verifyOptions.audience = aud; } if ( unverifiedPayload && diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index a10838921c..57f9437dd8 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -3,8 +3,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ActionEntry } from "../agent/production-agent.js"; const mockNotifyActionChange = vi.hoisted(() => vi.fn()); +const mockResolveOrgIdForEmail = vi.hoisted(() => vi.fn()); +const mockGetSession = vi.hoisted(() => vi.fn(async () => null)); +const mockGetOrgContext = vi.hoisted(() => + vi.fn(async () => ({ orgId: undefined })), +); vi.mock("h3", () => ({ + createError: (opts: any) => + Object.assign(new Error(opts?.statusMessage), opts), defineEventHandler: (handler: any) => handler, getMethod: (event: any) => event._method ?? "GET", getQuery: (event: any) => event._query ?? {}, @@ -30,12 +37,30 @@ vi.mock("./action-change.js", () => ({ notifyActionChange: (...args: unknown[]) => mockNotifyActionChange(...args), })); +// The adapter path in mountActionRoutes derives org via resolveAgentRunOrgId, +// which pulls from these modules. Mocked here so the owner-based fallback is +// deterministic without a live DB/session. +vi.mock("../org/context.js", () => ({ + resolveOrgIdForEmail: (...args: unknown[]) => + mockResolveOrgIdForEmail(...args), + getOrgContext: (...args: unknown[]) => mockGetOrgContext(...args), +})); + +vi.mock("./auth.js", () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})); + describe("mountActionRoutes", () => { afterEach(() => { delete process.env.AGENT_USER_EMAIL; delete process.env.AGENT_ORG_ID; delete process.env.AGENT_USER_TIMEZONE; mockNotifyActionChange.mockReset(); + mockResolveOrgIdForEmail.mockReset(); + mockGetSession.mockReset(); + mockGetSession.mockResolvedValue(null); + mockGetOrgContext.mockReset(); + mockGetOrgContext.mockResolvedValue({ orgId: undefined }); vi.restoreAllMocks(); }); @@ -877,7 +902,10 @@ describe("mountActionRoutes", () => { expect(received.userEmail).toBe("session-user@example.com"); }); - it("defers to getOwnerFromEvent when resolveCaller throws", async () => { + it("hard-rejects with 401 when resolveCaller throws (session chain not consulted)", async () => { + // Contract: a throw means "the credential is mine but invalid". It must NOT + // fall through to getOwnerFromEvent/getSession — otherwise a forged A2A + // bearer plus a live same-origin cookie would run as the session user. const { mountActionRoutes } = await import("./action-routes.js"); const mounted: Array<{ path: string; handler: any }> = []; const nitroApp = { @@ -886,13 +914,9 @@ describe("mountActionRoutes", () => { ), }; const getOwnerFromEvent = vi.fn(async () => "session-user@example.com"); - let received: any; const actions: Record = { "do-thing": { - run: vi.fn(async (_params, ctx) => { - received = ctx; - return { ok: true }; - }), + run: vi.fn(async () => ({ ok: true })), } as any, }; @@ -905,14 +929,66 @@ describe("mountActionRoutes", () => { }, }); + await expect( + mounted[0].handler({ + _method: "POST", + _headers: {}, + context: {}, + req: { json: async () => ({}) }, + }), + ).rejects.toMatchObject({ statusCode: 401 }); + + expect(getOwnerFromEvent).not.toHaveBeenCalled(); + expect(actions["do-thing"].run).not.toHaveBeenCalled(); + }); + + it("scopes the adapter-resolved caller to an owner-derived orgId", async () => { + // The app's resolveOrgId is session-backed and yields null for an A2A + // caller. The adapter path must still land the correct org by reusing + // core's owner-based fallback (resolveOrgIdForEmail) so org-scoped writes + // don't persist with org_id NULL. + const { mountActionRoutes } = await import("./action-routes.js"); + const { getRequestOrgId } = await import("./request-context.js"); + mockResolveOrgIdForEmail.mockResolvedValue("org-owner-derived"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = { ctx, requestOrgId: getRequestOrgId() }; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent: async () => "session-user@example.com", + // Session-backed org resolution returns null for an A2A caller. + resolveOrgId: async () => null, + actionRouteAuth: { + resolveCaller: async () => ({ + owner: "a2a-caller@example.com", + anonymous: false, + }), + }, + }); + await mounted[0].handler({ _method: "POST", _headers: {}, - context: {}, + context: {} as Record, req: { json: async () => ({}) }, }); - expect(getOwnerFromEvent).toHaveBeenCalledTimes(1); - expect(received.userEmail).toBe("session-user@example.com"); + expect(mockResolveOrgIdForEmail).toHaveBeenCalledWith( + "a2a-caller@example.com", + ); + expect(received.ctx.orgId).toBe("org-owner-derived"); + expect(received.requestOrgId).toBe("org-owner-derived"); }); }); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index d16f960c85..ba5348e39a 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -1,4 +1,5 @@ import { + createError, defineEventHandler, setResponseStatus, setResponseHeader, @@ -19,6 +20,7 @@ import { } from "../shared/mcp-embed-headers.js"; import { notifyActionChange } from "./action-change.js"; import { + resolveAgentRunOrgId, seedAgentRunOwnerContext, type AgentRunOwnerContext, } from "./agent-run-context.js"; @@ -178,10 +180,18 @@ function handleOptionsRequest(event: any): string { */ export interface ActionRouteAuthAdapter { /** - * Resolve a caller from the raw event before the cookie/bearer chain. Return - * the resolved owner context to run the action scoped to that caller, or - * `null` to defer to `getOwnerFromEvent` / `getSession`. A thrown error is - * treated the same as returning `null` (the framework chain still runs). + * Resolve a caller from the raw event before the cookie/bearer chain. + * + * - Return the resolved owner context to run the action scoped to that + * caller (the org is then derived the same way the framework chain derives + * it, including the owner-based fallback). + * - Return `null` when the credential isn't yours to judge — the request + * defers to `getOwnerFromEvent` / `getSession`. + * - THROW to hard-reject: the credential is present but invalid (e.g. an + * expired or forged A2A bearer). The action route responds 401 and does + * NOT fall through to the cookie/session chain, so a valid same-origin + * session cookie can't be used to execute the request as the logged-in + * user. Do not throw merely to signal "not mine" — return `null` for that. */ resolveCaller?: ( event: any, @@ -293,24 +303,34 @@ export function mountActionRoutes( // identities the framework's getSession chain doesn't understand (e.g. // an A2A JWT). A resolved caller is seeded onto the event context so any // downstream resolveAgentRunOwnerContext (nested agent runs) sees the - // same identity; a null return (or a throw) falls through to - // getOwnerFromEvent below. The adapter is only consulted for the action - // route, so it can't affect other surfaces. - let resolvedByAdapter = false; + // same identity. The adapter is only consulted for the action route, so + // it can't affect other surfaces. + // + // Contract: `resolveCaller` returning `null` means "this credential + // isn't mine — defer to the cookie/session chain below". THROWING means + // "the credential is mine but invalid" (e.g. an expired/forged A2A + // bearer) and is a hard rejection: we surface a 401 instead of falling + // through, so a live same-origin session cookie can't silently execute + // the request as the logged-in user. + let resolvedCaller: AgentRunOwnerContext | null = null; if (options?.actionRouteAuth?.resolveCaller) { + let caller: AgentRunOwnerContext | null; try { - const caller = await options.actionRouteAuth.resolveCaller(event); - if (caller) { - seedAgentRunOwnerContext(event, caller); - userEmail = caller.owner; - userName = caller.name; - resolvedByAdapter = true; - } + caller = await options.actionRouteAuth.resolveCaller(event); } catch { - // Adapter failure defers to the framework auth chain below. + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + if (caller) { + seedAgentRunOwnerContext(event, caller); + userEmail = caller.owner; + userName = caller.name; + resolvedCaller = caller; } } - if (!resolvedByAdapter && options?.getOwnerFromEvent) { + if (!resolvedCaller && options?.getOwnerFromEvent) { try { userEmail = await options.getOwnerFromEvent(event); userName = options?.getUserNameFromEvent @@ -328,9 +348,24 @@ export function mountActionRoutes( } } } - const orgId = options?.resolveOrgId - ? ((await options.resolveOrgId(event)) ?? undefined) - : undefined; + // Org scoping. When the adapter resolved the caller, the app's + // `resolveOrgId` (implemented via getSession) returns null for an A2A + // caller — which would leave org-scoped writes with org_id NULL. Reuse + // core's own org resolver so the adapter path picks up the same + // owner-derived fallback the framework auth chain does. Non-adapter + // callers keep the original resolveOrgId-only behavior. + let orgId: string | undefined; + if (resolvedCaller) { + orgId = await resolveAgentRunOrgId({ + event, + ownerContext: resolvedCaller, + resolveOrgId: options?.resolveOrgId, + }); + } else { + orgId = options?.resolveOrgId + ? ((await options.resolveOrgId(event)) ?? undefined) + : undefined; + } const timezone = readTimezoneHeader(event); return runWithRequestContext( diff --git a/packages/core/src/server/agent-chat/plugin-options.ts b/packages/core/src/server/agent-chat/plugin-options.ts index 188fa91a16..aff3e13bb9 100644 --- a/packages/core/src/server/agent-chat/plugin-options.ts +++ b/packages/core/src/server/agent-chat/plugin-options.ts @@ -112,7 +112,11 @@ export interface AgentChatPluginOptions { * cookie/bearer `getSession` chain, letting an app accept caller identities * `getSession` doesn't understand (e.g. an A2A JWT verified with * `verifyA2AToken`) declaratively, instead of pre-seeding request context - * from a Nitro `request` hook. Returning `null` defers to the normal chain. + * from a Nitro `request` hook. + * + * Returning `null` defers to the normal chain; THROWING hard-rejects with a + * 401 (an invalid/forged credential must not fall through to a same-origin + * session cookie). See {@link import("../action-routes.js").ActionRouteAuthAdapter}. */ actionRouteAuth?: import("../action-routes.js").ActionRouteAuthAdapter; /** From b3509383711918c8120daf79ceadbb3907fadb10 Mon Sep 17 00:00:00 2001 From: ptahdunbar Date: Sun, 12 Jul 2026 13:04:27 -0400 Subject: [PATCH 3/4] fix(core): derive adapter-caller org exclusively from the verified credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request can carry both a valid A2A bearer and an unrelated same-origin browser cookie. The adapter path previously resolved org via the app's session-backed resolveOrgId first, so the token caller's actions could execute under the cookie user's org. Adapter-resolved callers now take their org only from the credential: the adapter-returned orgId (ActionRouteResolvedCaller) or the owner-email membership lookup — never ambient session/org state. --- .changeset/a2a-action-route-auth.md | 8 +- .../core/src/server/action-routes.spec.ts | 147 +++++++++++++++++- packages/core/src/server/action-routes.ts | 73 ++++++--- .../src/server/agent-chat/plugin-options.ts | 5 +- packages/core/src/server/index.ts | 1 + 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/.changeset/a2a-action-route-auth.md b/.changeset/a2a-action-route-auth.md index cf3547ca61..eb51e9d369 100644 --- a/.changeset/a2a-action-route-auth.md +++ b/.changeset/a2a-action-route-auth.md @@ -17,7 +17,11 @@ stop reaching into core internals: JWTs declaratively instead of intercepting Nitro's `request` hook. Returning `null` defers to the existing framework auth chain; throwing hard-rejects the request with a 401 so an invalid credential can't fall through to a - same-origin session cookie. A resolved caller's org is derived with the same - owner-based fallback the framework chain uses, so A2A writes stay org-scoped. + same-origin session cookie. A resolved caller's org comes exclusively from + the verified credential — the adapter-returned `orgId` + (`ActionRouteResolvedCaller`) or the owner-email membership lookup — never + from ambient session/org cookie state, so a request carrying both a valid + A2A bearer and an unrelated browser cookie can't execute under the cookie + user's org. A2A writes stay org-scoped. All additive — existing callers are unaffected. diff --git a/packages/core/src/server/action-routes.spec.ts b/packages/core/src/server/action-routes.spec.ts index 57f9437dd8..04977c92dc 100644 --- a/packages/core/src/server/action-routes.spec.ts +++ b/packages/core/src/server/action-routes.spec.ts @@ -37,8 +37,8 @@ vi.mock("./action-change.js", () => ({ notifyActionChange: (...args: unknown[]) => mockNotifyActionChange(...args), })); -// The adapter path in mountActionRoutes derives org via resolveAgentRunOrgId, -// which pulls from these modules. Mocked here so the owner-based fallback is +// The adapter path in mountActionRoutes derives org from the verified caller +// via resolveOrgIdForEmail. Mocked here so the owner-based lookup is // deterministic without a live DB/session. vi.mock("../org/context.js", () => ({ resolveOrgIdForEmail: (...args: unknown[]) => @@ -991,4 +991,147 @@ describe("mountActionRoutes", () => { expect(received.ctx.orgId).toBe("org-owner-derived"); expect(received.requestOrgId).toBe("org-owner-derived"); }); + + it("never lets the ambient session org override the adapter caller's org", async () => { + // A request can carry BOTH a valid A2A bearer and an unrelated same-origin + // browser cookie. The identity comes from the token, so the org must too: + // the session-backed resolveOrgId (and getSession/getOrgContext) must not + // be consulted, or the token caller would execute under the cookie user's + // org — a cross-org confusion. + const { mountActionRoutes } = await import("./action-routes.js"); + const { getRequestOrgId } = await import("./request-context.js"); + mockResolveOrgIdForEmail.mockResolvedValue("org-of-a2a-caller"); + mockGetSession.mockResolvedValue({ + email: "cookie-user@example.com", + orgId: "org-of-cookie-user", + } as any); + mockGetOrgContext.mockResolvedValue({ orgId: "org-of-cookie-user" }); + const resolveOrgId = vi.fn(async () => "org-of-cookie-user"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = { ctx, requestOrgId: getRequestOrgId() }; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent: async () => "cookie-user@example.com", + resolveOrgId, + actionRouteAuth: { + resolveCaller: async () => ({ + owner: "a2a-caller@example.com", + anonymous: false, + }), + }, + }); + + await mounted[0].handler({ + _method: "POST", + _headers: {}, + context: {} as Record, + req: { json: async () => ({}) }, + }); + + expect(received.ctx.orgId).toBe("org-of-a2a-caller"); + expect(received.requestOrgId).toBe("org-of-a2a-caller"); + expect(resolveOrgId).not.toHaveBeenCalled(); + expect(mockGetSession).not.toHaveBeenCalled(); + expect(mockGetOrgContext).not.toHaveBeenCalled(); + }); + + it("uses the adapter-asserted orgId verbatim, skipping the owner lookup", async () => { + // When the adapter verified an org from the credential itself (e.g. the + // A2A token's org claim), that org wins and no membership lookup runs. + const { mountActionRoutes } = await import("./action-routes.js"); + const { getRequestOrgId } = await import("./request-context.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + let received: any; + const actions: Record = { + "do-thing": { + run: vi.fn(async (_params, ctx) => { + received = { ctx, requestOrgId: getRequestOrgId() }; + return { ok: true }; + }), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + resolveOrgId: async () => "org-of-cookie-user", + actionRouteAuth: { + resolveCaller: async () => ({ + owner: "a2a-caller@example.com", + anonymous: false, + orgId: "org-from-token", + }), + }, + }); + + await mounted[0].handler({ + _method: "POST", + _headers: {}, + context: {} as Record, + req: { json: async () => ({}) }, + }); + + expect(received.ctx.orgId).toBe("org-from-token"); + expect(received.requestOrgId).toBe("org-from-token"); + expect(mockResolveOrgIdForEmail).not.toHaveBeenCalled(); + }); + + it("does not seed the adapter's orgId into the owner context", async () => { + // seedAgentRunOwnerContext carries identity only; org is request-context + // state. Downstream consumers of the seeded owner context must not see + // adapter-specific fields. + const { mountActionRoutes } = await import("./action-routes.js"); + const { AGENT_RUN_OWNER_CONTEXT_KEY } = + await import("./agent-run-context.js"); + const mounted: Array<{ path: string; handler: any }> = []; + const nitroApp = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const actions: Record = { + "do-thing": { run: vi.fn(async () => ({ ok: true })) } as any, + }; + + mountActionRoutes(nitroApp, actions, { + actionRouteAuth: { + resolveCaller: async () => ({ + owner: "a2a-caller@example.com", + anonymous: false, + name: "A2A Caller", + orgId: "org-from-token", + }), + }, + }); + + const event = { + _method: "POST", + _headers: {}, + context: {} as Record, + req: { json: async () => ({}) }, + }; + await mounted[0].handler(event); + + expect(event.context[AGENT_RUN_OWNER_CONTEXT_KEY]).toEqual({ + owner: "a2a-caller@example.com", + anonymous: false, + name: "A2A Caller", + }); + }); }); diff --git a/packages/core/src/server/action-routes.ts b/packages/core/src/server/action-routes.ts index ba5348e39a..63a65228a7 100644 --- a/packages/core/src/server/action-routes.ts +++ b/packages/core/src/server/action-routes.ts @@ -11,6 +11,7 @@ import { import { isAgentActionStopError } from "../action.js"; import type { ActionEntry } from "../agent/production-agent.js"; +import { resolveOrgIdForEmail } from "../org/context.js"; import { readBody } from "../server/h3-helpers.js"; import { EMBED_TARGET_HEADER } from "../shared/embed-auth.js"; import { @@ -20,7 +21,6 @@ import { } from "../shared/mcp-embed-headers.js"; import { notifyActionChange } from "./action-change.js"; import { - resolveAgentRunOrgId, seedAgentRunOwnerContext, type AgentRunOwnerContext, } from "./agent-run-context.js"; @@ -178,13 +178,27 @@ function handleOptionsRequest(event: any): string { * * Scoped to `/_agent-native/actions/*` only — it does not affect other routes. */ +export type ActionRouteResolvedCaller = AgentRunOwnerContext & { + /** + * Org to scope the request to, verified from the same credential as the + * caller identity (e.g. the A2A token's org claim). When omitted, the org + * is derived from the verified owner email via the framework's owner→org + * membership lookup. The ambient session/org state on the request is never + * consulted for adapter-resolved callers: a request can carry both a valid + * A2A bearer and an unrelated browser cookie, and the cookie user's org + * must not leak into the token caller's request context. + */ + orgId?: string; +}; + export interface ActionRouteAuthAdapter { /** * Resolve a caller from the raw event before the cookie/bearer chain. * - * - Return the resolved owner context to run the action scoped to that - * caller (the org is then derived the same way the framework chain derives - * it, including the owner-based fallback). + * - Return the resolved caller to run the action scoped to that identity. + * Org scoping comes exclusively from the caller: the returned `orgId` if + * set, otherwise the owner-email membership lookup — never from the + * request's session cookie or org context. * - Return `null` when the credential isn't yours to judge — the request * defers to `getOwnerFromEvent` / `getSession`. * - THROW to hard-reject: the credential is present but invalid (e.g. an @@ -195,7 +209,10 @@ export interface ActionRouteAuthAdapter { */ resolveCaller?: ( event: any, - ) => AgentRunOwnerContext | null | Promise; + ) => + | ActionRouteResolvedCaller + | null + | Promise; } export interface MountActionRoutesOptions { @@ -215,6 +232,12 @@ export interface MountActionRoutesOptions { actionRouteAuth?: ActionRouteAuthAdapter; } +function normalizeOrgId(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + function isAuthResolutionFailure(error: unknown): boolean { if (!error || typeof error !== "object") return false; const maybeStatus = error as { @@ -312,9 +335,9 @@ export function mountActionRoutes( // bearer) and is a hard rejection: we surface a 401 instead of falling // through, so a live same-origin session cookie can't silently execute // the request as the logged-in user. - let resolvedCaller: AgentRunOwnerContext | null = null; + let resolvedCaller: ActionRouteResolvedCaller | null = null; if (options?.actionRouteAuth?.resolveCaller) { - let caller: AgentRunOwnerContext | null; + let caller: ActionRouteResolvedCaller | null; try { caller = await options.actionRouteAuth.resolveCaller(event); } catch { @@ -324,7 +347,11 @@ export function mountActionRoutes( }); } if (caller) { - seedAgentRunOwnerContext(event, caller); + seedAgentRunOwnerContext(event, { + owner: caller.owner, + anonymous: caller.anonymous, + name: caller.name, + }); userEmail = caller.owner; userName = caller.name; resolvedCaller = caller; @@ -348,19 +375,27 @@ export function mountActionRoutes( } } } - // Org scoping. When the adapter resolved the caller, the app's - // `resolveOrgId` (implemented via getSession) returns null for an A2A - // caller — which would leave org-scoped writes with org_id NULL. Reuse - // core's own org resolver so the adapter path picks up the same - // owner-derived fallback the framework auth chain does. Non-adapter - // callers keep the original resolveOrgId-only behavior. + // Org scoping. For adapter-resolved callers the org must come + // exclusively from the verified credential: the adapter-asserted + // orgId when present, otherwise the owner-email membership lookup. + // The request's ambient session/org state (`resolveOrgId`, usually + // getSession-backed) is deliberately NOT consulted — a request can + // carry both a valid A2A bearer and an unrelated same-origin browser + // cookie, and the cookie user's org must not become the org the + // token caller's actions execute under. Non-adapter callers keep the + // original resolveOrgId-only behavior. let orgId: string | undefined; if (resolvedCaller) { - orgId = await resolveAgentRunOrgId({ - event, - ownerContext: resolvedCaller, - resolveOrgId: options?.resolveOrgId, - }); + orgId = normalizeOrgId(resolvedCaller.orgId); + if (!orgId && resolvedCaller.owner && !resolvedCaller.anonymous) { + try { + orgId = normalizeOrgId( + await resolveOrgIdForEmail(resolvedCaller.owner), + ); + } catch { + // Org tables may not exist yet on first boot. + } + } } else { orgId = options?.resolveOrgId ? ((await options.resolveOrgId(event)) ?? undefined) diff --git a/packages/core/src/server/agent-chat/plugin-options.ts b/packages/core/src/server/agent-chat/plugin-options.ts index aff3e13bb9..488ced7a2a 100644 --- a/packages/core/src/server/agent-chat/plugin-options.ts +++ b/packages/core/src/server/agent-chat/plugin-options.ts @@ -116,7 +116,10 @@ export interface AgentChatPluginOptions { * * Returning `null` defers to the normal chain; THROWING hard-rejects with a * 401 (an invalid/forged credential must not fall through to a same-origin - * session cookie). See {@link import("../action-routes.js").ActionRouteAuthAdapter}. + * session cookie). A resolved caller's org comes exclusively from the + * verified credential — the returned `orgId` or the owner-email membership + * lookup — never from the request's session cookie. + * See {@link import("../action-routes.js").ActionRouteAuthAdapter}. */ actionRouteAuth?: import("../action-routes.js").ActionRouteAuthAdapter; /** diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 02a13119b5..97b0dc4235 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -321,6 +321,7 @@ export { mountActionRoutes, type MountActionRoutesOptions, type ActionRouteAuthAdapter, + type ActionRouteResolvedCaller, } from "./action-routes.js"; export { AGENT_RUN_OWNER_CONTEXT_KEY, From 9104b61cca0b7d2e1adc6fc763c028f2c6454cc4 Mon Sep 17 00:00:00 2001 From: ptahdunbar Date: Sun, 12 Jul 2026 17:33:51 -0400 Subject: [PATCH 4/4] docs(core): update expectedJwtAudience JSDoc to match fail-closed aud behavior --- packages/core/src/a2a/server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index a90d03fb72..1d17f3408b 100644 --- a/packages/core/src/a2a/server.ts +++ b/packages/core/src/a2a/server.ts @@ -59,9 +59,11 @@ function addSecretCandidate( /** * Resolve the audience (`aud`) value to expect in an inbound JWT. We use the * receiver's app URL — it's the natural identifier of "who this token was - * minted for". Falls back to undefined when no app URL is configured, in - * which case the audience check is skipped (backward-compat with tokens - * minted before the audience claim shipped). + * minted for". Returns undefined when no app URL is configured and no request + * host is derivable; `verifyA2AToken` then rejects any token that carries an + * `aud` claim (fail closed — a correctly signed token minted for another + * service must not verify here). Only tokens without an `aud` claim (minted + * before the audience claim shipped) skip the audience check. */ function expectedJwtAudience(event: any | undefined): string | undefined { const fromEnv =