diff --git a/.changeset/a2a-action-route-auth.md b/.changeset/a2a-action-route-auth.md new file mode 100644 index 0000000000..eb51e9d369 --- /dev/null +++ b/.changeset/a2a-action-route-auth.md @@ -0,0 +1,27 @@ +--- +"@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` 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 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/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..51c016276e 100644 --- a/packages/core/src/a2a/server.spec.ts +++ b/packages/core/src/a2a/server.spec.ts @@ -299,6 +299,178 @@ 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 }); + }); + + 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( config: A2AConfig, routePrefix?: string, diff --git a/packages/core/src/a2a/server.ts b/packages/core/src/a2a/server.ts index 0b4ad9894c..1d17f3408b 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; } @@ -58,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 = @@ -80,9 +83,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, @@ -132,8 +150,15 @@ 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 ad4a64fae8..04977c92dc 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 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[]) => + 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(); }); @@ -777,4 +802,336 @@ 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("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 = { + use: vi.fn((path: string, handler: any) => + mounted.push({ path, handler }), + ), + }; + const getOwnerFromEvent = vi.fn(async () => "session-user@example.com"); + const actions: Record = { + "do-thing": { + run: vi.fn(async () => ({ ok: true })), + } as any, + }; + + mountActionRoutes(nitroApp, actions, { + getOwnerFromEvent, + actionRouteAuth: { + resolveCaller: async () => { + throw new Error("verifier exploded"); + }, + }, + }); + + 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: {} as Record, + req: { json: async () => ({}) }, + }); + + expect(mockResolveOrgIdForEmail).toHaveBeenCalledWith( + "a2a-caller@example.com", + ); + 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 4a1985fe89..63a65228a7 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, @@ -10,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 { @@ -18,6 +20,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 +170,51 @@ 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 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 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 + * 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, + ) => + | ActionRouteResolvedCaller + | null + | Promise; +} + export interface MountActionRoutesOptions { /** Resolve owner email from the H3 event (for data scoping). */ getOwnerFromEvent?: (event: any) => string | Promise; @@ -173,6 +224,18 @@ 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 normalizeOrgId(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; } function isAuthResolutionFailure(error: unknown): boolean { @@ -259,7 +322,42 @@ 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. 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: ActionRouteResolvedCaller | null = null; + if (options?.actionRouteAuth?.resolveCaller) { + let caller: ActionRouteResolvedCaller | null; + try { + caller = await options.actionRouteAuth.resolveCaller(event); + } catch { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + if (caller) { + seedAgentRunOwnerContext(event, { + owner: caller.owner, + anonymous: caller.anonymous, + name: caller.name, + }); + userEmail = caller.owner; + userName = caller.name; + resolvedCaller = caller; + } + } + if (!resolvedCaller && options?.getOwnerFromEvent) { try { userEmail = await options.getOwnerFromEvent(event); userName = options?.getUserNameFromEvent @@ -277,9 +375,32 @@ export function mountActionRoutes( } } } - const orgId = options?.resolveOrgId - ? ((await options.resolveOrgId(event)) ?? undefined) - : undefined; + // 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 = 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) + : undefined; + } const timezone = readTimezoneHeader(event); return runWithRequestContext( 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..488ced7a2a 100644 --- a/packages/core/src/server/agent-chat/plugin-options.ts +++ b/packages/core/src/server/agent-chat/plugin-options.ts @@ -106,6 +106,22 @@ 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; THROWING hard-rejects with a + * 401 (an invalid/forged credential must not fall through to a same-origin + * 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; /** * 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..97b0dc4235 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -320,7 +320,14 @@ export { export { mountActionRoutes, type MountActionRoutesOptions, + type ActionRouteAuthAdapter, + type ActionRouteResolvedCaller, } from "./action-routes.js"; +export { + AGENT_RUN_OWNER_CONTEXT_KEY, + seedAgentRunOwnerContext, + type AgentRunOwnerContext, +} from "./agent-run-context.js"; export { runWithRequestContext, hasRequestContext,