Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/tidy-embed-session-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@agent-native/core": patch
---

Scope MCP Apps embed sessions to their resource and declared actions, and require browser cookie sessions for UI capabilities and approvals.

Apps with a custom `AuthOptions.getSession` resolver must also configure the cookie-only `getBrowserSession` hook to use UI capabilities and browser approvals. Without it, these operations return `401` and the server logs a migration warning.

MCP Apps embeds behind a proxy require the public origin configured through `APP_URL` or the workspace gateway URL unless the proxy connects over loopback.

Use the embed origin consistently for widget CSP and keep ordinary sign-in session lookup independent of embed origin validation.

Keep Visual Edit capabilities on their declared action set and automatic source synchronization on the signed-in user's own write access.

Keep card read manifests limited to their declared reads, and require a browser session for localhost write consent.
2 changes: 2 additions & 0 deletions packages/core/docs/content/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ export default createAuthPlugin({
});
```

Custom authentication must also configure the optional `AuthOptions.getBrowserSession` callback for UI capabilities and browser approvals. It accepts an `H3Event` and returns `AuthSession | null`, like `getSession`, but must read browser cookies only, never bearer tokens, query tokens, or other headers. Without this explicit hook, those browser-only operations return `401`.

## Public Workspace Apps {#public-workspace-apps}

Workspace apps are internal by default. To let anonymous visitors load a public
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/ar-SA/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ export default createAuthPlugin({
});
```

يجب أيضًا تهيئة رد الاتصال الاختياري `AuthOptions.getBrowserSession` للمصادقة المخصصة من أجل إمكانات واجهة المستخدم وموافقات المتصفح. يقبل `H3Event` ويعيد `AuthSession | null`، مثل `getSession`، لكن يجب أن يقرأ ملفات تعريف ارتباط المتصفح فقط، ولا يقرأ مطلقًا رموز الحامل أو رموز الاستعلام أو أي ترويسات أخرى. من دون هذا الخطاف الصريح، تُرجع هذه العمليات المخصصة للمتصفح `401`.

## تطبيقات مساحة العمل العامة {#public-workspace-apps}

تعتبر تطبيقات مساحة العمل داخلية بشكل افتراضي. للسماح للزائرين المجهولين بتحميل ملف عام
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/de-DE/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ export default createAuthPlugin({
});
```

Bei benutzerdefinierter Authentifizierung muss auch der optionale Rückruf `AuthOptions.getBrowserSession` für UI-Funktionen und Browserfreigaben konfiguriert werden. Er akzeptiert ein `H3Event` und gibt wie `getSession` `AuthSession | null` zurück, darf aber ausschließlich Browser-Cookies und niemals Bearer-Token, Query-Token oder andere Header lesen. Ohne diesen expliziten Hook geben diese reinen Browseroperationen `401` zurück.

## Öffentliche Workspace-Apps {#public-workspace-apps}

Workspace-Apps sind standardmäßig intern. Damit anonyme Besucher eine öffentliche laden können
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/es-ES/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```

La autenticación personalizada también debe configurar la devolución de llamada opcional `AuthOptions.getBrowserSession` para las funciones de la interfaz de usuario y las aprobaciones del navegador. Acepta un `H3Event` y devuelve `AuthSession | null`, como `getSession`, pero solo debe leer cookies del navegador, nunca tokens bearer, tokens de consulta ni otros encabezados. Sin este hook explícito, esas operaciones exclusivas del navegador devuelven `401`.

## Aplicaciones de espacios de trabajo públicos {#public-workspace-apps}

Las aplicaciones de Workspace son internas de forma predeterminada. Para permitir que visitantes anónimos carguen un público
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/fr-FR/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```

L’authentification personnalisée doit également configurer le rappel facultatif `AuthOptions.getBrowserSession` pour les fonctionnalités de l’interface et les approbations du navigateur. Il accepte un `H3Event` et renvoie `AuthSession | null`, comme `getSession`, mais doit lire uniquement les cookies du navigateur, jamais les jetons bearer, les jetons de requête ni d’autres en-têtes. Sans ce hook explicite, ces opérations réservées au navigateur renvoient `401`.

## Applications de l'espace de travail public {#public-workspace-apps}

Les applications Workspace sont internes par défaut. Pour permettre aux visiteurs anonymes de charger un public
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/hi-IN/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ export default createAuthPlugin({
});
```

कस्टम प्रमाणीकरण में UI क्षमताओं और ब्राउज़र अनुमतियों के लिए वैकल्पिक `AuthOptions.getBrowserSession` कॉलबैक भी कॉन्फ़िगर करना होगा। यह `H3Event` स्वीकार करता है और `getSession` की तरह `AuthSession | null` लौटाता है, लेकिन इसे केवल ब्राउज़र कुकीज़ पढ़नी चाहिए; bearer token, query token या अन्य headers कभी नहीं। इस स्पष्ट hook के बिना, केवल ब्राउज़र के लिए उपलब्ध ये कार्रवाइयाँ `401` लौटाती हैं।

## सार्वजनिक कार्यक्षेत्र ऐप्स {#public-workspace-apps}

वर्कस्पेस ऐप्स डिफ़ॉल्ट रूप से आंतरिक होते हैं। अनाम आगंतुकों को सार्वजनिक रूप से लोड करने देने के लिए
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/ja-JP/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ export default createAuthPlugin({
});
```

カスタム認証では、UI 機能とブラウザー承認のために、オプションの `AuthOptions.getBrowserSession` コールバックも設定する必要があります。これは `H3Event` を受け取り、`getSession` と同様に `AuthSession | null` を返しますが、読み取るのはブラウザーの Cookie のみで、Bearer トークン、クエリトークン、その他のヘッダーは読み取ってはいけません。この明示的なフックがない場合、ブラウザー専用のこれらの操作は `401` を返します。

## パブリック ワークスペース アプリ {#public-workspace-apps}

ワークスペース アプリはデフォルトで内部にあります。匿名の訪問者がパブリックをロードできるようにするには
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/ko-KR/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ export default createAuthPlugin({
});
```

사용자 지정 인증은 UI 기능과 브라우저 승인에 사용할 선택적 `AuthOptions.getBrowserSession` 콜백도 구성해야 합니다. 이 콜백은 `H3Event`를 받고 `getSession`처럼 `AuthSession | null`을 반환하지만, 브라우저 쿠키만 읽어야 하며 bearer 토큰, 쿼리 토큰 또는 다른 헤더는 절대 읽으면 안 됩니다. 이 명시적 훅이 없으면 브라우저 전용 작업은 `401`을 반환합니다.

## 공용 작업 공간 앱 {#public-workspace-apps}

Workspace 앱은 기본적으로 내부용입니다. 익명의 방문자가 공개를 로드할 수 있도록 하려면
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/pt-BR/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ export default createAuthPlugin({
});
```

A autenticação personalizada também deve configurar o retorno de chamada opcional `AuthOptions.getBrowserSession` para recursos da interface e aprovações do navegador. Ele aceita um `H3Event` e retorna `AuthSession | null`, como `getSession`, mas deve ler apenas cookies do navegador, nunca tokens bearer, tokens de consulta ou outros cabeçalhos. Sem esse hook explícito, essas operações exclusivas do navegador retornam `401`.

## Aplicativos de espaço de trabalho público {#public-workspace-apps}

Os aplicativos do Workspace são internos por padrão. Para permitir que visitantes anônimos carreguem um arquivo público
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/zh-CN/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ export default createAuthPlugin({
});
```

自定义身份验证还必须配置可选回调 `AuthOptions.getBrowserSession`,用于 UI 功能和浏览器审批。它接收 `H3Event` 并返回 `AuthSession | null`,与 `getSession` 相同,但只能读取浏览器 Cookie,绝不能读取 bearer token、查询 token 或其他标头。没有此显式 hook 时,这些仅限浏览器的操作会返回 `401`。

## 公共工作区应用 {#public-workspace-apps}

工作区应用程序默认为内部应用程序。让匿名访问者加载公共
Expand Down
2 changes: 2 additions & 0 deletions packages/core/docs/content/locales/zh-TW/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ export default createAuthPlugin({
});
```

自訂驗證也必須設定選用回呼 `AuthOptions.getBrowserSession`,供 UI 功能和瀏覽器核准使用。它接受 `H3Event` 並傳回 `AuthSession | null`,與 `getSession` 相同,但只能讀取瀏覽器 Cookie,絕不可讀取 bearer token、查詢 token 或其他標頭。沒有這個明確的 hook 時,這些僅限瀏覽器的操作會傳回 `401`。

## 公開工作區應用 {#public-workspace-apps}

工作區應用程式預設為內部應用程式。讓匿名訪客載入公開
Expand Down
107 changes: 102 additions & 5 deletions packages/core/src/a2a/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const setResponseStatusMock = vi.hoisted(() =>
);
const setResponseHeaderMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
const getBrowserCookieSessionMock = vi.hoisted(() => vi.fn());
const runWithRequestContextMock = vi.hoisted(() =>
vi.fn((_context: unknown, fn: () => unknown) => fn()),
);
Expand Down Expand Up @@ -92,7 +93,10 @@ vi.mock("../org/service-principal-guard.js", async (importActual) => ({
recordServicePrincipalDenial: recordServicePrincipalDenialMock,
}));

vi.mock("../server/auth.js", () => ({ getSession: getSessionMock }));
vi.mock("../server/auth.js", () => ({
getSession: getSessionMock,
getBrowserCookieSession: getBrowserCookieSessionMock,
}));

vi.mock("../server/request-context.js", () => ({
runWithRequestContext: runWithRequestContextMock,
Expand Down Expand Up @@ -142,6 +146,7 @@ describe("mountA2A auth", () => {
evaluateServicePrincipalMock.mockResolvedValue({ status: "not-service" });
recordServicePrincipalDenialMock.mockReset();
getSessionMock.mockReset();
getBrowserCookieSessionMock.mockReset().mockResolvedValue(null);
runWithRequestContextMock.mockClear();
getApprovalMock.mockReset();
claimApprovalMock.mockReset();
Expand Down Expand Up @@ -623,11 +628,83 @@ describe("mountA2A auth", () => {
expect(getApprovalMock).not.toHaveBeenCalled();
});

it("prevents approval pages from being framed", async () => {
it.each(["GET", "POST"])(
"refuses a bearer-only approval %s without executing",
async (method) => {
getSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
const approval = {
id: "approval-1",
taskId: "task-1",
ownerEmail: "owner@example.com",
orgId: "org-1",
tool: "send-email",
input: {},
status: "pending",
expiresAt: Date.now() + 10_000,
};
getApprovalMock.mockResolvedValue(approval);
claimApprovalMock.mockResolvedValue(approval);
const executeApproval = vi.fn(async () => ({
status: "completed" as const,
output: "Email sent",
}));
const handler = await mountedA2AApprovalHandler({
...config,
executeApproval,
});
const event = {
method,
path: "/approval-1",
headers: { authorization: "Bearer test-login-session" },
context: {},
};
const response = await handler(event);
expect(executeApproval).not.toHaveBeenCalled();
expect(response).toEqual({
error: "Sign in to review this approval",
});
expect(event).toMatchObject({ _status: 401 });
expect(getApprovalMock).not.toHaveBeenCalled();
expect(claimApprovalMock).not.toHaveBeenCalled();
},
);

it("refuses an approval POST without browser origin evidence", async () => {
getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
getSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
const executeApproval = vi.fn();
const handler = await mountedA2AApprovalHandler({
...config,
executeApproval,
});
const event = {
method: "POST",
path: "/approval-1",
headers: { cookie: "an_session=test-browser-session" },
context: {},
};
await expect(handler(event)).resolves.toEqual({
error: "Cross-origin approval denied",
});
expect(event).toMatchObject({ _status: 403 });
expect(claimApprovalMock).not.toHaveBeenCalled();
expect(executeApproval).not.toHaveBeenCalled();
});

it("prevents approval pages from being framed", async () => {
getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
getApprovalMock.mockResolvedValue({
id: "approval-1",
ownerEmail: "owner@example.com",
Expand All @@ -642,6 +719,7 @@ describe("mountA2A auth", () => {
const response = await handler({
method: "GET",
path: "/approval-1",
headers: { cookie: "an_session=test-browser-session" },
context: {},
});

Expand All @@ -664,7 +742,7 @@ describe("mountA2A auth", () => {
});

it("makes an interrupted processing approval available for safe recovery", async () => {
getSessionMock.mockResolvedValue({
getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
Expand All @@ -683,6 +761,7 @@ describe("mountA2A auth", () => {
const response = await handler({
method: "GET",
path: "/approval-1",
headers: { cookie: "an_session=test-browser-session" },
context: {},
});

Expand All @@ -706,11 +785,13 @@ describe("mountA2A auth", () => {
result: null,
expiresAt: Date.now() + 10_000,
};
getSessionMock.mockResolvedValue({
getBrowserCookieSessionMock.mockResolvedValue({
email: "owner@example.com",
orgId: "org-1",
});
claimApprovalMock.mockResolvedValue(approval);
claimApprovalMock
.mockResolvedValueOnce(approval)
.mockResolvedValueOnce(null);
getApprovalMock.mockResolvedValue(approval);
const executeApproval = vi.fn(async () => ({
status: "completed" as const,
Expand All @@ -730,6 +811,7 @@ describe("mountA2A auth", () => {
host: "mail.example",
origin: "https://mail.example",
"sec-fetch-site": "same-origin",
cookie: "an_session=test-browser-session",
},
}),
).resolves.toEqual({ status: "completed", output: "Email sent" });
Expand All @@ -752,6 +834,21 @@ describe("mountA2A auth", () => {
"Email sent",
);
expect(runLeaseMocks.releaseTurnExecutionLease).toHaveBeenCalledTimes(1);

const replay = {
method: "POST",
path: "/approval-1",
context: {},
headers: {
cookie: "an_session=test-browser-session",
"sec-fetch-site": "same-origin",
},
};
await expect(handler(replay)).resolves.toMatchObject({
error: expect.stringContaining("cannot be run again"),
});
expect(replay).toMatchObject({ _status: 409 });
expect(executeApproval).toHaveBeenCalledOnce();
});

it("allows legacy apiKeyEnv bearer auth even when A2A_SECRET is configured", async () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/a2a/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { readDeployCredentialEnv } from "../server/credential-provider.js";
import { getH3App } from "../server/framework-request-handler.js";
import { publicFrameworkPath } from "../server/framework-route-prefix.js";
import { readBody } from "../server/h3-helpers.js";
import { isSameOriginRequest } from "../server/request-origin.js";
import { hasSameOriginBrowserEvidence } from "../server/request-origin.js";
import type { AgentProfileMetadata } from "../shared/agent-profile.js";
import { generateAgentCard } from "./agent-card.js";
import { addAgentProfileExamples } from "./app-profile.js";
Expand Down Expand Up @@ -834,8 +834,8 @@ export function mountA2A(
return { error: "Approval not found" };
}

const { getSession } = await import("../server/auth.js");
const session = await getSession(event);
const { getBrowserCookieSession } = await import("../server/auth.js");
const session = await getBrowserCookieSession(event);
if (!session?.email) {
setResponseStatus(event, 401);
return { error: "Sign in to review this approval" };
Expand Down Expand Up @@ -885,7 +885,7 @@ export function mountA2A(
setResponseStatus(event, 405);
return { error: "Method not allowed" };
}
if (!isSameOriginRequest(event)) {
if (!hasSameOriginBrowserEvidence(event)) {
setResponseStatus(event, 403);
return { error: "Cross-origin approval denied" };
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ export interface ActionMcpAppResourceConfig {
title?: string;
description?: string;
html: string | ActionMcpAppHtmlBuilder;
requiresEmbedSession?: boolean;
mimeType?: typeof MCP_APP_MIME_TYPE;
_meta?: Record<string, unknown>;
csp?: ActionMcpAppCsp | ActionMcpAppCspBuilder;
Expand Down
Loading
Loading