diff --git a/changelog.d/fixes/12888-a2a-dashboard-auth.md b/changelog.d/fixes/12888-a2a-dashboard-auth.md new file mode 100644 index 0000000000..8e6b45fa81 --- /dev/null +++ b/changelog.d/fixes/12888-a2a-dashboard-auth.md @@ -0,0 +1 @@ +- fix(a2a): accept the dashboard's own session cookie on /a2a so "Run message/send" no longer fails with "Unauthorized: missing or invalid API key" (#12888) diff --git a/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx index cc3bd7d072..278844cfd0 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx @@ -201,6 +201,7 @@ export default function A2ADashboardPage() { const response = await fetch("/a2a", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "same-origin", body: JSON.stringify({ jsonrpc: "2.0", id: "dashboard-send", @@ -234,6 +235,7 @@ export default function A2ADashboardPage() { const response = await fetch("/a2a", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "same-origin", body: JSON.stringify({ jsonrpc: "2.0", id: "dashboard-stream", diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts index dfe3fc73a7..4f023b53ff 100644 --- a/src/app/a2a/route.ts +++ b/src/app/a2a/route.ts @@ -187,7 +187,7 @@ export async function POST(req: NextRequest) { const tm = getTaskManager(); // GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's // owner id (hashed API key; undefined under the keyless local-first posture). - const callerOwner = resolveA2AOwner(req); + const callerOwner = await resolveA2AOwner(req); // A2A 1.0 method-name compatibility (SendMessage → message/send, etc.) const isV1Method = method in V1_METHOD_ALIASES; diff --git a/src/app/api/a2a/_auth.ts b/src/app/api/a2a/_auth.ts index 2ec286db91..340fa20733 100644 --- a/src/app/api/a2a/_auth.ts +++ b/src/app/api/a2a/_auth.ts @@ -35,7 +35,7 @@ export async function authorizeA2ATaskRoute(request: Request): Promise { const apiKey = extractApiKey(req as NextRequest); if (isRequireApiKeyEnabled()) { - return apiKey ? await isValidApiKey(apiKey) : false; + if (apiKey) return isValidApiKey(apiKey); + // #12888: mirror clientApiPolicy's dashboard-session fallback so the + // dashboard's own A2A playground (no Authorization header, session + // cookie only) is accepted the same way /api/v1/* already accepts it. + return isDashboardSessionAuthenticated(req); } const configuredKey = process.env.OMNIROUTE_API_KEY; if (configuredKey) { - return apiKey ? tokensMatch(apiKey, configuredKey) : false; + if (apiKey) return tokensMatch(apiKey, configuredKey); + return isDashboardSessionAuthenticated(req); } // No API key required and none configured — allow (keyless local-first). @@ -43,11 +49,15 @@ export async function authenticateA2ARequest(req: NextRequest | Request): Promis /** * Owner id for task scoping (GHSA-jcm5-6wpp-wjj8): a stable hash of the - * caller's API key, or `undefined` when the call carries no key (keyless - * posture — ownerless tasks stay visible to everyone, by design). + * caller's API key, `"dashboard"` for a session-authenticated caller with no + * API key (#12888 — keeps dashboard-originated tasks scoped consistently + * instead of falling into the ownerless keyless bucket), or `undefined` when + * the call carries neither (keyless posture — ownerless tasks stay visible to + * everyone, by design). */ -export function resolveA2AOwner(req: NextRequest | Request): string | undefined { +export async function resolveA2AOwner(req: NextRequest | Request): Promise { const apiKey = extractApiKey(req as NextRequest); - if (!apiKey) return undefined; - return createHash("sha256").update(apiKey).digest("hex").slice(0, 32); + if (apiKey) return createHash("sha256").update(apiKey).digest("hex").slice(0, 32); + if (await isDashboardSessionAuthenticated(req)) return "dashboard"; + return undefined; } diff --git a/tests/unit/a2a-dashboard-session-auth.test.ts b/tests/unit/a2a-dashboard-session-auth.test.ts new file mode 100644 index 0000000000..79d20f3867 --- /dev/null +++ b/tests/unit/a2a-dashboard-session-auth.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +process.env.OMNIROUTE_API_KEY = "test-configured-key-12888"; +process.env.JWT_SECRET = "test-jwt-secret-for-probe-12888"; + +const { authenticateA2ARequest, resolveA2AOwner } = await import("../../src/lib/a2a/authenticate.ts"); +const { isDashboardSessionAuthenticated } = await import("../../src/shared/utils/apiAuth.ts"); +const { SignJWT } = await import("jose"); + +async function buildSessionRequest(): Promise { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const sessionToken = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(secret); + + return { + headers: new Headers({ cookie: `auth_token=${sessionToken}` }), + cookies: { get: () => undefined }, + nextUrl: { searchParams: new URLSearchParams() }, + url: "http://localhost:20128/a2a", + }; +} + +test("A2A route accepts a dashboard-session-authenticated request with no Authorization header (bug #12888)", async () => { + const fakeRequest = await buildSessionRequest(); + + const dashboardSessionOk = await isDashboardSessionAuthenticated(fakeRequest as never); + assert.equal(dashboardSessionOk, true, "expected the dashboard session cookie itself to be valid"); + + const a2aAuthOk = await authenticateA2ARequest(fakeRequest as never); + assert.equal( + a2aAuthOk, + true, + "/a2a should accept the dashboard's own session-authenticated requests " + + "(matching /api/v1/* behavior) but currently requires an explicit Authorization header" + ); +}); + +test("A2A route still rejects a request with neither a valid API key nor a valid session cookie", async () => { + const fakeRequest = { + headers: new Headers(), + cookies: { get: () => undefined }, + nextUrl: { searchParams: new URLSearchParams() }, + url: "http://localhost:20128/a2a", + }; + + const a2aAuthOk = await authenticateA2ARequest(fakeRequest as never); + assert.equal(a2aAuthOk, false, "unauthenticated, keyless requests must still be rejected"); +}); + +test("resolveA2AOwner() returns a stable 'dashboard' owner id for a session-authenticated caller with no API key", async () => { + const fakeRequest = await buildSessionRequest(); + const owner = await resolveA2AOwner(fakeRequest as never); + assert.equal(owner, "dashboard"); +}); diff --git a/tests/unit/a2a-history-route.test.ts b/tests/unit/a2a-history-route.test.ts index a4748bdf63..8ccdcced65 100644 --- a/tests/unit/a2a-history-route.test.ts +++ b/tests/unit/a2a-history-route.test.ts @@ -154,7 +154,7 @@ test("GET history owner-scoping: an API-key caller sees only its own + ownerless const ownerAReq = new Request("http://localhost/api/a2a/tasks/history", { headers: AUTH_HEADERS, }); - const ownerA = resolveA2AOwner(ownerAReq as never); + const ownerA = await resolveA2AOwner(ownerAReq as never); assert.ok(ownerA, "the shared key resolves to a stable owner hash"); seedRow({ id: "owned-by-a", apiKeyId: ownerA ?? null, createdAt: "2026-01-01T00:00:00.000Z" }); diff --git a/tests/unit/a2a-task-owner-idor.test.ts b/tests/unit/a2a-task-owner-idor.test.ts index aaa35142c8..d05d4deffe 100644 --- a/tests/unit/a2a-task-owner-idor.test.ts +++ b/tests/unit/a2a-task-owner-idor.test.ts @@ -123,7 +123,7 @@ describe("REST /api/a2a/tasks/[id] — authentication (GHSA-jcm5)", () => { // And the same task IS visible to its owner (owner hash derived from the key). const owned = tm.createTask( { skill: "smart-routing", messages: [] }, - resolveA2AOwner(req as never) + await resolveA2AOwner(req as never) ); const res2 = await restGet.GET( new Request(`http://localhost/api/a2a/tasks/${owned.id}`, {