From 605787176ca371d4c8c7dce6338928d058beaffe Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:34:02 +0300 Subject: [PATCH 1/5] feat(api): MCP OAuth authorization server, RFC-0044 phase 2 (#144) Run the deployment's own OAuth 2.1 authorization server so remote MCP clients can authorize against the Studio's accounts: - Better Auth family 1.7.17 -> 1.7.18, plus @better-auth/mcp and @better-auth/oauth-provider 1.7.18 and jose 6.2.12 (the copy better-auth resolves); the pins test guards both. - mcp() + jwt() in createInstance when AuthOptions.mcpOauth is set: code + PKCE (S256) and refresh grants, Ed25519 JWT access tokens (1 h, aud = /mcp, iss = /api/auth), refresh rotation with a 30 s reuse window, 5 min codes. Better Auth logs go through AuthOptions.log; a wrong API key no longer logs an error. - Migration 0031_oauth: the plugins' DDL from compileMigrations() on 1.7.18 and Cloudhal's agent_grants table. - /mcp verifies the token locally (signature, iss, aud, exp, typ) and reads the grant row: the same Principal a key produces, narrowed by the grant and the member's role; revoked grants and removed members get the invalid_token challenge. - Consent on Studio's /oauth/consent screen through /api/agent-grants/consent: scopes narrowed by the installation role, mode capped, grant + agent_owner + audit in one batch. The provider's own consent, client CRUD and other non-flow endpoints are fenced. - /api/agent-grants list and revoke; AS metadata at the path-inserted and root well-known URLs; the admin Worker and `cloudhal studio --base-url` turn it on, the playground stays out. - Shared suite on better-sqlite3 and Miniflare D1, a full flow on workerd, ADR-0045, ADR-0004/0010/0011 updated. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/mcp-oauth-authorization-server.md | 8 + apps/studio/scripts/design-lint.ts | 6 + apps/studio/scripts/screenshots.ts | 4 +- apps/studio/src/i18n/ar.ts | 5 +- apps/studio/src/i18n/de.ts | 5 +- apps/studio/src/i18n/en.ts | 3 + apps/studio/src/i18n/es.ts | 5 +- apps/studio/src/i18n/fa.ts | 5 +- apps/studio/src/i18n/fr.ts | 5 +- apps/studio/src/i18n/generated/params.ts | 6 + apps/studio/src/i18n/he.ts | 5 +- apps/studio/src/i18n/hi.ts | 5 +- apps/studio/src/i18n/id.ts | 5 +- apps/studio/src/i18n/it.ts | 5 +- apps/studio/src/i18n/ja.ts | 5 +- apps/studio/src/i18n/ko.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.ar.ts | 44 ++ apps/studio/src/i18n/lazy/oauthConsent.de.ts | 48 ++ apps/studio/src/i18n/lazy/oauthConsent.en.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.es.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.fa.ts | 48 ++ apps/studio/src/i18n/lazy/oauthConsent.fr.ts | 49 +++ apps/studio/src/i18n/lazy/oauthConsent.he.ts | 45 ++ apps/studio/src/i18n/lazy/oauthConsent.hi.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.id.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.it.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.ja.ts | 46 ++ apps/studio/src/i18n/lazy/oauthConsent.ko.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.nl.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.pl.ts | 48 ++ .../src/i18n/lazy/oauthConsent.pt-BR.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.ru.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.tr.ts | 47 ++ apps/studio/src/i18n/lazy/oauthConsent.uk.ts | 47 ++ .../src/i18n/lazy/oauthConsent.zh-Hans.ts | 42 ++ apps/studio/src/i18n/namespaces.ts | 2 + apps/studio/src/i18n/nl.ts | 5 +- apps/studio/src/i18n/pl.ts | 5 +- apps/studio/src/i18n/pt-BR.ts | 5 +- apps/studio/src/i18n/ru.ts | 5 +- apps/studio/src/i18n/tr.ts | 5 +- apps/studio/src/i18n/uk.ts | 5 +- apps/studio/src/i18n/zh-Hans.ts | 4 +- apps/studio/src/lib/api.ts | 3 + apps/studio/src/lib/api/agent-grants.ts | 55 +++ apps/studio/src/lib/api/team.ts | 2 + apps/studio/src/router.tsx | 11 + apps/studio/src/routes/LoginPage.tsx | 28 +- apps/studio/src/routes/OAuthConsentPage.tsx | 213 +++++++++ apps/studio/test/login-setup.test.ts | 13 +- apps/studio/test/oauth-consent.test.tsx | 135 ++++++ docs/adr/0004-mcp-server.md | 2 +- docs/adr/0010-auth-better-auth.md | 2 +- docs/adr/0011-agent-layer.md | 2 +- docs/adr/0045-mcp-authorization-server.md | 29 ++ docs/adr/README.md | 1 + docs/design/DESIGN.md | 3 + packages/api/package.json | 3 + packages/api/src/app.ts | 51 ++- packages/api/src/auth-node.ts | 10 +- packages/api/src/auth.ts | 34 ++ packages/api/src/auth/access-token.ts | 123 ++++++ packages/api/src/auth/agent-consent.ts | 252 +++++++++++ packages/api/src/auth/agent-grants.ts | 108 +++++ packages/api/src/auth/deps.ts | 24 +- packages/api/src/auth/grants.ts | 218 +++++++++ packages/api/src/auth/instance.ts | 36 +- packages/api/src/auth/oauth-clients.ts | 48 ++ packages/api/src/auth/oauth-deps.ts | 63 +++ packages/api/src/auth/oauth.ts | 146 ++++++ packages/api/src/auth/principal.ts | 6 + packages/api/src/auth/token-principal.ts | 80 ++++ packages/api/src/auth/types.ts | 36 ++ packages/api/src/context.ts | 7 + packages/api/src/oauth-server.ts | 135 ++++++ packages/api/src/playground.ts | 5 + packages/api/src/routes/instance.ts | 2 + packages/api/src/routes/members.ts | 1 + packages/api/src/scopes.ts | 8 + packages/api/src/staff.ts | 9 + packages/api/src/testing-mcp-oauth-server.ts | 416 ++++++++++++++++++ packages/api/src/testing-oauth-harness.ts | 266 +++++++++++ packages/api/test/better-auth-pins.test.ts | 22 + packages/api/test/mcp-oauth-server.test.ts | 34 ++ packages/api/test/route-scopes.test.ts | 2 + packages/api/test/studio-endpoints.test.ts | 1 + packages/cli/src/commands/studio.ts | 34 +- packages/cli/test/cloudflare-deploy.test.ts | 115 +++++ packages/cloudflare/src/admin-worker.ts | 50 ++- packages/storage/migrations/0031_oauth.ts | 60 +++ packages/storage/migrations/index.ts | 2 + packages/storage/test/migrate.test.ts | 55 +++ pnpm-lock.yaml | 175 +++++--- pnpm-workspace.yaml | 11 +- scripts/size-baseline.json | 6 +- 95 files changed, 4030 insertions(+), 128 deletions(-) create mode 100644 .changeset/mcp-oauth-authorization-server.md create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.ar.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.de.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.en.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.es.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.fa.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.fr.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.he.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.hi.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.id.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.it.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.ja.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.ko.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.nl.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.pl.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.ru.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.tr.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.uk.ts create mode 100644 apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts create mode 100644 apps/studio/src/lib/api/agent-grants.ts create mode 100644 apps/studio/src/routes/OAuthConsentPage.tsx create mode 100644 apps/studio/test/oauth-consent.test.tsx create mode 100644 docs/adr/0045-mcp-authorization-server.md create mode 100644 packages/api/src/auth/access-token.ts create mode 100644 packages/api/src/auth/agent-consent.ts create mode 100644 packages/api/src/auth/agent-grants.ts create mode 100644 packages/api/src/auth/grants.ts create mode 100644 packages/api/src/auth/oauth-clients.ts create mode 100644 packages/api/src/auth/oauth-deps.ts create mode 100644 packages/api/src/auth/oauth.ts create mode 100644 packages/api/src/auth/token-principal.ts create mode 100644 packages/api/src/oauth-server.ts create mode 100644 packages/api/src/testing-mcp-oauth-server.ts create mode 100644 packages/api/src/testing-oauth-harness.ts create mode 100644 packages/api/test/mcp-oauth-server.test.ts create mode 100644 packages/storage/migrations/0031_oauth.ts diff --git a/.changeset/mcp-oauth-authorization-server.md b/.changeset/mcp-oauth-authorization-server.md new file mode 100644 index 00000000..d4fab35f --- /dev/null +++ b/.changeset/mcp-oauth-authorization-server.md @@ -0,0 +1,8 @@ +--- +"@cloudhal/storage": patch +"@cloudhal/api": patch +"@cloudhal/cloudflare": patch +"@cloudhal/cli": patch +--- + +Remote MCP clients can now authorize with OAuth 2.1 against the deployment itself (RFC-0044 Phase 2, ADR-0045). Better Auth's `mcp()` and `jwt()` plugins run in the Studio's auth instance when the runtime has a stable public origin: the admin Worker uses its origin, `cloudhal studio` needs `--base-url` / `CLOUDHAL_BASE_URL` (it logs why OAuth is off otherwise), and `CLOUDHAL_MCP_OAUTH=off` turns it off. Access tokens are Ed25519 JWTs bound to `/mcp` for one hour, refresh tokens rotate with a 30-second reuse window, and each connection is approved on Studio's new consent screen (`/oauth/consent`), which narrows the requested scopes by the member's role and records an `agent_grants` row (migration `0031_oauth`). A token resolves to the same principal an agent key does; revoking a connection (`DELETE /api/agent-grants/:id`) or removing the member refuses it on its next call. The Better Auth family moves to 1.7.18. Client registration (CIMD, DCR) and the "Connected agents" screen follow in Phase 3. diff --git a/apps/studio/scripts/design-lint.ts b/apps/studio/scripts/design-lint.ts index 80f6e4d3..e5be3689 100644 --- a/apps/studio/scripts/design-lint.ts +++ b/apps/studio/scripts/design-lint.ts @@ -76,6 +76,8 @@ export const ROUTE_TEMPLATES: Record = { "routes/CommenterPage.tsx": "editor", "routes/AssetPage.tsx": "editor", "routes/LoginPage.tsx": "auth", + // RFC-0044 §4: an MCP client's authorization request, approved or denied by the member. + "routes/OAuthConsentPage.tsx": "consent", "routes/MembersPage.tsx": "list", "routes/InvitePage.tsx": "token", "routes/ResetPasswordPage.tsx": "token", @@ -100,6 +102,7 @@ export type Template = | "auth" | "token" | "recover" + | "consent" | "shell"; /** What each template must be composed from (names must appear in the file). DESIGN.md §2a–2f. */ @@ -141,6 +144,9 @@ export const TEMPLATE_REQUIRES: Record = { // `/forgot-password` (RFC-0011, DESIGN.md §2r): the auth frame, one email field, the neutral // answer as a Banner, the way back to sign in. recover: ["AuthLayout", "Input", "Button", "Banner", "LinkButton"], + // OAuth consent (RFC-0044 §4, DESIGN.md §2): a narrow `PageLayout` of `SectionCard`s (who + // asks, the scopes as checkboxes, the mode picker) with Approve and Deny in the last footer. + consent: ["PageLayout", "title=", "SectionCard", "Checkbox.Group", "ModePicker", "Button"], shell: ["AppFrame", "Sidebar.Group", "Sidebar.MenuButton", "SidebarSwitcher"], }; diff --git a/apps/studio/scripts/screenshots.ts b/apps/studio/scripts/screenshots.ts index 055213f8..ca5a79f0 100644 --- a/apps/studio/scripts/screenshots.ts +++ b/apps/studio/scripts/screenshots.ts @@ -232,7 +232,9 @@ async function shoot(opts: Options, width: number): Promise { result?: { data?: string }; }; const tag = `${opts.scheme ? `_${opts.scheme}` : ""}${opts.locale ? `_${opts.locale}` : ""}${opts.pseudo ? (opts.pseudo === "rtl" ? "_pseudo-rtl" : "_pseudo") : ""}`; - const name = `${p.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "root"}${action ? "_action" : ""}${tag}_${width}.png`; + // The path names the file; a query string (e.g. `/oauth/consent`'s signed request) does not. + const bare = p.split("?")[0] ?? p; + const name = `${bare.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "root"}${action ? "_action" : ""}${tag}_${width}.png`; const file = path.join(opts.out, name); writeFileSync(file, Buffer.from(shot.result?.data ?? "", "base64")); const notes = [ diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index 961c91eb..04ac709d 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: de0b53138e89cd52 +// hash: 85836f8ccd597d42 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1227,6 +1227,7 @@ export const messages: Messages = { members: "الفريق والأدوار", agentKeys: "مفاتيح الوكلاء", mcp: "خادم MCP", + mcpOauth: "ربط الوكلاء عبر URL", activity: "سجل النشاط", previewLinks: "روابط المعاينة", deployTransfer: "التوزيع والنقل", @@ -1238,6 +1239,8 @@ export const messages: Messages = { "ينضم الأشخاص برابط دعوة ويعملون بدور: المالك أو المسؤول أو المحرر أو المؤلف أو المساهم.", agentKeys: "يسجّل الوكلاء ومهام CI الدخول بمفاتيح تنشئها أنت.", mcp: "يتصل الوكلاء على /mcp.", + mcpOauth: + "تتصل Claude وChatGPT وعملاء MCP الآخرون بعنوان /mcp وحده؛ وأنت توافق على كل اتصال.", activity: "يُسجَّل كل استدعاء أداة لكل موقع.", previewLinks: "روابط موقّعة تعرض المسودات على الموقع المستضاف.", deployTransfer: "يمكن لـcloudhal push وpull الوصول إلى هذا الخادم.", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index 288ac73d..ce722103 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 844f14a5128a34f6 +// hash: daeaa96e378bb3ee import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1275,6 +1275,7 @@ export const messages: Messages = { members: "Team und Rollen", agentKeys: "Agent-Schlüssel", mcp: "MCP-Server", + mcpOauth: "Agents per URL verbinden", activity: "Aktivitätsprotokoll", previewLinks: "Vorschau-Links", deployTransfer: "Deployment und Übertragung", @@ -1286,6 +1287,8 @@ export const messages: Messages = { "Personen treten per Einladungslink bei und arbeiten in einer Rolle: Inhaber, Administrator, Redakteur, Autor oder Mitarbeiter.", agentKeys: "Agents und CI-Jobs melden sich mit Schlüsseln an, die du erstellst.", mcp: "Agents verbinden sich unter /mcp.", + mcpOauth: + "Claude, ChatGPT und andere MCP-Clients verbinden sich allein mit der /mcp-Adresse; du bestätigst jede Verbindung.", activity: "Jeder Tool-Aufruf wird pro Website protokolliert.", previewLinks: "Signierte Links, die Entwürfe auf der bereitgestellten Website zeigen.", deployTransfer: "cloudhal push und pull können diesen Server erreichen.", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index 8ac5c121..df374cd5 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -1194,6 +1194,7 @@ export const en = { members: "Team and roles", agentKeys: "Agent keys", mcp: "MCP server", + mcpOauth: "Connect agents by URL", activity: "Activity log", previewLinks: "Preview links", deployTransfer: "Deploy and transfer", @@ -1205,6 +1206,8 @@ export const en = { "People join by invite link and work in a role: Owner, Administrator, Editor, Author or Contributor.", agentKeys: "Agents and CI jobs sign in with keys you create.", mcp: "Agents connect at /mcp.", + mcpOauth: + "Claude, ChatGPT and other MCP clients connect with the /mcp address alone; you approve each connection.", activity: "Every tool call is recorded per site.", previewLinks: "Signed links that show drafts on the deployed site.", deployTransfer: "cloudhal push and pull can reach this server.", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index 6b6c7fcb..fcbb4849 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9ea0d8cd7f137822 +// hash: 0f2cd4b34c9478e2 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1269,6 +1269,7 @@ export const messages: Messages = { members: "Equipo y roles", agentKeys: "Claves de agente", mcp: "Servidor MCP", + mcpOauth: "Conectar agentes por URL", activity: "Registro de actividad", previewLinks: "Enlaces de vista previa", deployTransfer: "Implementación y transferencia", @@ -1280,6 +1281,8 @@ export const messages: Messages = { "Las personas se unen con un enlace de invitación y trabajan con un rol: Propietario, Administrador, Editor, Autor o Colaborador.", agentKeys: "Los agentes y los trabajos de CI inician sesión con claves que creas tú.", mcp: "Los agentes se conectan en /mcp.", + mcpOauth: + "Claude, ChatGPT y otros clientes MCP se conectan solo con la dirección /mcp; tú apruebas cada conexión.", activity: "Cada llamada a herramientas se registra por sitio.", previewLinks: "Enlaces firmados que muestran los borradores en el sitio implementado.", deployTransfer: "cloudhal push y pull pueden llegar a este servidor.", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index 7e82c721..1e509581 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a4005ab45f17daa9 +// hash: 192ba5182a0b1c83 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1248,6 +1248,7 @@ export const messages: Messages = { members: "تیم و نقش‌ها", agentKeys: "کلیدهای عامل", mcp: "سرور MCP", + mcpOauth: "اتصال عامل‌ها با URL", activity: "گزارش فعالیت", previewLinks: "پیوندهای پیش‌نمایش", deployTransfer: "استقرار و انتقال", @@ -1259,6 +1260,8 @@ export const messages: Messages = { "افراد با پیوند دعوت می‌پیوندند و در یک نقش کار می‌کنند: مالک، مدیر، ویرایشگر، نویسنده یا همکار.", agentKeys: "عامل‌ها و کارهای CI با کلیدهایی که شما می‌سازید وارد می‌شوند.", mcp: "عامل‌ها در /mcp متصل می‌شوند.", + mcpOauth: + "Claude، ChatGPT و دیگر کلاینت‌های MCP فقط با نشانی /mcp وصل می‌شوند؛ هر اتصال را شما تأیید می‌کنید.", activity: "هر فراخوانی ابزار برای هر سایت ثبت می‌شود.", previewLinks: "پیوندهای امضاشده‌ای که پیش‌نویس‌ها را در سایت مستقرشده نشان می‌دهند.", deployTransfer: "cloudhal push و pull می‌توانند به این سرور برسند.", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index 4caa5651..2142f2af 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 82efd0e745377b9e +// hash: f0d31150d942eca9 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1288,6 +1288,7 @@ export const messages: Messages = { members: "Équipe et rôles", agentKeys: "Clés d’agent", mcp: "Serveur MCP", + mcpOauth: "Connecter des agents par URL", activity: "Journal d’activité", previewLinks: "Liens d’aperçu", deployTransfer: "Déploiement et transfert", @@ -1299,6 +1300,8 @@ export const messages: Messages = { "Les personnes rejoignent l’équipe par lien d’invitation et travaillent avec un rôle : Propriétaire, Administrateur, Éditeur, Auteur ou Contributeur.", agentKeys: "Les agents et jobs de CI se connectent avec des clés que vous créez.", mcp: "Les agents se connectent à /mcp.", + mcpOauth: + "Claude, ChatGPT et d'autres clients MCP se connectent avec la seule adresse /mcp ; vous approuvez chaque connexion.", activity: "Chaque appel d’outil est consigné par site.", previewLinks: "Des liens signés qui montrent les brouillons sur le site déployé.", deployTransfer: "cloudhal push et pull peuvent joindre ce serveur.", diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index d4b514df..914d2968 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -545,6 +545,12 @@ export interface MessageParams { "notifications.roleChanged.title": { role: string | number }; "notifications.toast.endingBody": { time: string | number; from: string | number }; "notifications.toast.endingTitle": { name: string | number; mode: string | number; left: string | number }; + "oauthConsent.scopes.narrowed": { label: string | number; scope: string | number }; + "oauthConsent.scopes.notInRole": { label: string | number }; + "oauthConsent.scopes.sites": { count: number }; + "oauthConsent.title": { client: string | number }; + "oauthConsent.who.fromHost": { host: string | number }; + "oauthConsent.who.sendsBack": { host: string | number }; "overview.commentsWaiting": { count: number }; "pageStatus.leftOut": { count: number }; "pageStatus.publishTitle": { title: string | number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index b395be8d..5cf95b2b 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fb9798d4e9369c1e +// hash: f5a406ed081a3a33 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1223,6 +1223,7 @@ export const messages: Messages = { members: "צוות ותפקידים", agentKeys: "מפתחות סוכנים", mcp: "שרת MCP", + mcpOauth: "חיבור סוכנים לפי URL", activity: "יומן פעילות", previewLinks: "קישורי תצוגה מקדימה", deployTransfer: "פריסה והעברה", @@ -1234,6 +1235,8 @@ export const messages: Messages = { "אנשים מצטרפים בקישור הזמנה ועובדים בתפקיד: בעלים, מנהל מערכת, עורך, כותב או תורם.", agentKeys: "סוכנים ומשימות CI מתחברים עם מפתחות שיוצרים עבורם.", mcp: "סוכנים מתחברים בכתובת /mcp.", + mcpOauth: + "Claude,‏ ChatGPT ולקוחות MCP אחרים מתחברים עם הכתובת /mcp בלבד; כל חיבור מאושר על ידך.", activity: "כל קריאה לכלי נרשמת לפי אתר.", previewLinks: "קישורים חתומים שמציגים טיוטות באתר הפרוס.", deployTransfer: "cloudhal push ו-pull יכולים להגיע לשרת הזה.", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index 32156c90..e73245b4 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 882e56567d7611da +// hash: f08d54d1a4ada98a import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1218,6 +1218,7 @@ export const messages: Messages = { members: "टीम और भूमिकाएँ", agentKeys: "एजेंट कुंजियाँ", mcp: "MCP सर्वर", + mcpOauth: "URL से एजेंट कनेक्ट करें", activity: "गतिविधि लॉग", previewLinks: "प्रीव्यू लिंक", deployTransfer: "डिप्लॉय और ट्रांसफ़र", @@ -1229,6 +1230,8 @@ export const messages: Messages = { "लोग आमंत्रण लिंक से जुड़ते हैं और एक भूमिका में काम करते हैं: स्वामी, व्यवस्थापक, संपादक, लेखक या योगदानकर्ता।", agentKeys: "एजेंट और CI जॉब आपके बनाए कुंजियों से साइन इन करते हैं।", mcp: "एजेंट /mcp पर कनेक्ट होते हैं।", + mcpOauth: + "Claude, ChatGPT और दूसरे MCP क्लाइंट सिर्फ़ /mcp पते से कनेक्ट होते हैं; हर कनेक्शन आप मंज़ूर करते हैं।", activity: "हर टूल कॉल प्रति साइट दर्ज होती है।", previewLinks: "हस्ताक्षरित लिंक जो डिप्लॉय की गई साइट पर ड्राफ़्ट दिखाते हैं।", deployTransfer: "cloudhal push और pull इस सर्वर तक पहुँच सकते हैं।", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index bca2ac8a..59991529 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: fa6d4f782fb8d938 +// hash: 7fcb09869901645a import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1264,6 +1264,7 @@ export const messages: Messages = { members: "Tim dan peran", agentKeys: "Kunci agen", mcp: "Server MCP", + mcpOauth: "Hubungkan agen lewat URL", activity: "Log aktivitas", previewLinks: "Tautan pratinjau", deployTransfer: "Deploy dan transfer", @@ -1275,6 +1276,8 @@ export const messages: Messages = { "Orang bergabung melalui tautan undangan dan bekerja dalam satu peran: Pemilik, Administrator, Editor, Penulis, atau Kontributor.", agentKeys: "Agen dan tugas CI masuk dengan kunci yang Anda buat.", mcp: "Agen terhubung di /mcp.", + mcpOauth: + "Claude, ChatGPT, dan klien MCP lain terhubung hanya dengan alamat /mcp; Anda menyetujui setiap koneksi.", activity: "Setiap panggilan alat dicatat per situs.", previewLinks: "Tautan bertanda tangan yang menampilkan draf di situs yang di-deploy.", deployTransfer: "cloudhal push dan pull dapat menjangkau server ini.", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index 9b5280a1..c9d7b85a 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 2cfeac004b958452 +// hash: e478c29376b4d68b import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1264,6 +1264,7 @@ export const messages: Messages = { members: "Team e ruoli", agentKeys: "Chiavi agente", mcp: "Server MCP", + mcpOauth: "Collega agenti tramite URL", activity: "Log delle attività", previewLinks: "Link di anteprima", deployTransfer: "Deploy e trasferimento", @@ -1275,6 +1276,8 @@ export const messages: Messages = { "Le persone entrano con un link di invito e lavorano con un ruolo: Proprietario, Amministratore, Editor, Autore o Collaboratore.", agentKeys: "Agenti e job di CI accedono con chiavi che crei tu.", mcp: "Gli agenti si collegano a /mcp.", + mcpOauth: + "Claude, ChatGPT e altri client MCP si collegano con il solo indirizzo /mcp; approvi tu ogni connessione.", activity: "Ogni chiamata a uno strumento viene registrata per sito.", previewLinks: "Link firmati che mostrano le bozze sul sito distribuito.", deployTransfer: "cloudhal push e pull possono raggiungere questo server.", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index 52023920..f15546a1 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 45f31b8df1f976e3 +// hash: 9b9673e6d67b24d1 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1257,6 +1257,7 @@ export const messages: Messages = { members: "チームとロール", agentKeys: "エージェントキー", mcp: "MCPサーバー", + mcpOauth: "URL でエージェントを接続", activity: "アクティビティログ", previewLinks: "プレビューリンク", deployTransfer: "デプロイと転送", @@ -1268,6 +1269,8 @@ export const messages: Messages = { "メンバーは招待リンクで参加し、オーナー、管理者、編集者、作成者、寄稿者のいずれかのロールで作業します。", agentKeys: "エージェントやCIジョブは、あなたが作成したキーでサインインします。", mcp: "エージェントは/mcpで接続します。", + mcpOauth: + "Claude、ChatGPT などの MCP クライアントは /mcp のアドレスだけで接続し、接続ごとにあなたが承認します。", activity: "すべてのツール呼び出しがサイトごとに記録されます。", previewLinks: "デプロイ済みのサイトで下書きを表示する署名付きリンクです。", deployTransfer: "cloudhal pushとpullでこのサーバーにアクセスできます。", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index f7fda02e..cac879a7 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 165c309380bb3c4e +// hash: 23fad7f0119a599e import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1227,6 +1227,7 @@ export const messages: Messages = { members: "팀과 역할", agentKeys: "에이전트 키", mcp: "MCP 서버", + mcpOauth: "URL로 에이전트 연결", activity: "활동 로그", previewLinks: "미리보기 링크", deployTransfer: "배포 및 전송", @@ -1238,6 +1239,8 @@ export const messages: Messages = { "사람들은 초대 링크로 참여하고 소유자, 관리자, 편집자, 작성자, 기여자 중 한 역할로 일해요.", agentKeys: "에이전트와 CI 작업은 내가 만든 키로 로그인해요.", mcp: "에이전트는 /mcp로 연결해요.", + mcpOauth: + "Claude, ChatGPT 등 MCP 클라이언트는 /mcp 주소만으로 연결하며, 연결마다 내가 승인합니다.", activity: "모든 도구 호출이 사이트별로 기록돼요.", previewLinks: "배포된 사이트에서 초안을 보여 주는 서명된 링크예요.", deployTransfer: "cloudhal push와 pull로 이 서버에 접근할 수 있어요.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts new file mode 100644 index 00000000..0d7cd969 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts @@ -0,0 +1,44 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ar `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 6022774733fe70c9 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "ربط {client}", + description: "يطلب وكيل العمل في هذا الـStudio نيابةً عنك. تحقّق مما يُسمح له به قبل الموافقة.", + invalid: { + title: "لا يمكن استخدام هذا الطلب", + description: + "انتهت صلاحيته أو تمت الإجابة عنه أو تغيّر في الطريق. ابدأ الاتصال من الوكيل مرة أخرى.", + }, + who: { + title: "من يطلب", + fromHost: "يُعرَّف عبر {host}", + registered: "مسجّل في هذا الـStudio", + sendsBack: "يعيدك إلى {host}", + thisComputer: "يعيدك إلى تطبيق على هذا الحاسوب", + loopbackWarning: + "كل عنوان يمكن لهذا الوكيل أن يعيدك إليه موجود على هذا الحاسوب. وافق فقط إذا بدأت الاتصال من تطبيق هنا.", + stepUp: "هذا الوكيل متصل بالفعل ويطلب المزيد. ما يُسمح له به اليوم محدَّد.", + }, + scopes: { + title: "ما يُسمح له به", + description: "ألغِ تحديد ما لا ينبغي أن يفعله. يقرّر دورك الباقي: ما لا يمنحه يُستبعد.", + legend: "الأذونات", + notInRole: "{label} (ليس ضمن دورك)", + narrowed: "{label}، لعملك الخاص فقط ضمن دورك ({scope})", + offline: "يبقى متصلاً ويجدّد وصوله بنفسه حتى تفصله من صفحة الوكلاء.", + sites: + "{count, plural, zero {لا يصل إلى أي موقع.} one {يصل فقط إلى الموقع الذي تعمل عليه.} two {يصل فقط إلى الموقعين اللذين تعمل عليهما.} few {يصل فقط إلى # مواقع تعمل عليها.} many {يصل فقط إلى # موقعًا تعمل عليها.} other {يصل فقط إلى # موقع تعمل عليها.}}", + none: "اختر إذنًا واحدًا على الأقل.", + }, + mode: { + title: "طريقة عمله", + description: "مدى استقلالية عمل الوكيل. يمكنك تغيير ذلك لاحقًا.", + legend: "وضع الوكيل", + }, + approve: "موافقة", + deny: "رفض", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.de.ts b/apps/studio/src/i18n/lazy/oauthConsent.de.ts new file mode 100644 index 00000000..0167375b --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.de.ts @@ -0,0 +1,48 @@ +// Generated by `pnpm i18n:import` (ADR-0034): de `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: aaa7f80c24fe29a7 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} verbinden", + description: + "Ein Agent möchte in diesem Studio für dich arbeiten. Prüfe, was er darf, bevor du zustimmst.", + invalid: { + title: "Diese Anfrage kann nicht verwendet werden", + description: + "Sie ist abgelaufen, wurde schon beantwortet oder unterwegs verändert. Starte die Verbindung im Agent neu.", + }, + who: { + title: "Wer fragt", + fromHost: "Identifiziert durch {host}", + registered: "In diesem Studio registriert", + sendsBack: "Leitet dich zurück zu {host}", + thisComputer: "Leitet dich zurück zu einer App auf diesem Computer", + loopbackWarning: + "Jede Adresse, zu der dieser Agent dich zurückleiten kann, liegt auf diesem Computer. Stimme nur zu, wenn du die Verbindung aus einer App hier gestartet hast.", + stepUp: + "Dieser Agent ist schon verbunden und bittet um mehr. Was er heute darf, ist angehakt.", + }, + scopes: { + title: "Was er darf", + description: + "Entferne den Haken bei allem, was er nicht tun soll. Den Rest entscheidet deine Rolle: Was sie nicht gibt, bleibt weg.", + legend: "Berechtigungen", + notInRole: "{label} (nicht in deiner Rolle)", + narrowed: "{label}, nur für deine eigene Arbeit in deiner Rolle ({scope})", + offline: + "Er bleibt verbunden und erneuert seinen Zugriff selbst, bis du ihn auf der Seite Agents trennst.", + sites: + "{count, plural, one {Er erreicht nur die Website, an der du arbeitest.} other {Er erreicht nur die # Websites, an denen du arbeitest.}}", + none: "Wähle mindestens eine Berechtigung.", + }, + mode: { + title: "So arbeitet er", + description: "Wie selbstständig der Agent arbeiten darf. Du kannst es später ändern.", + legend: "Agent-Modus", + }, + approve: "Zustimmen", + deny: "Ablehnen", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.en.ts b/apps/studio/src/i18n/lazy/oauthConsent.en.ts new file mode 100644 index 00000000..c2f9fe2f --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.en.ts @@ -0,0 +1,47 @@ +/** + * English `oauthConsent.*`, a lazy message namespace (`../namespaces.ts`, DESIGN.md §1): the + * consent screen a remote MCP client's authorization request lands on (RFC-0044 §4). The source + * of truth for its keys; the other languages are drafts from `pnpm i18n:import`. + */ +export const messages = { + oauthConsent: { + title: "Connect {client}", + description: + "An agent asks to work in this Studio for you. Check what it may do before you approve.", + invalid: { + title: "This request can't be used", + description: + "It expired, was already answered, or was changed on the way. Start the connection again from the agent.", + }, + who: { + title: "Who is asking", + fromHost: "Identified by {host}", + registered: "Registered with this Studio", + sendsBack: "Sends you back to {host}", + thisComputer: "Sends you back to an app on this computer", + loopbackWarning: + "Every address this agent can send you back to is on this computer. Approve only if you started the connection from an app here.", + stepUp: "This agent is already connected and asks for more. What it may do today is ticked.", + }, + scopes: { + title: "What it may do", + description: + "Untick anything it should not do. Your role decides the rest: what it does not give is left out.", + legend: "Permissions", + notInRole: "{label} (not in your role)", + narrowed: "{label}, only for your own work in your role ({scope})", + offline: + "It stays connected and renews its access by itself until you disconnect it on the Agents page.", + sites: + "{count, plural, one {It reaches only the site you work on.} other {It reaches only the # sites you work on.}}", + none: "Choose at least one permission.", + }, + mode: { + title: "How it works", + description: "How independently the agent may work. You can change it later.", + legend: "Agent mode", + }, + approve: "Approve", + deny: "Deny", + }, +} as const; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.es.ts b/apps/studio/src/i18n/lazy/oauthConsent.es.ts new file mode 100644 index 00000000..5fe21a99 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.es.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): es `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: bfc09daf09ffae18 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Conectar {client}", + description: + "Un agente pide trabajar en este Studio por ti. Revisa lo que puede hacer antes de aprobar.", + invalid: { + title: "No se puede usar esta solicitud", + description: + "Caducó, ya se respondió o se modificó por el camino. Vuelve a iniciar la conexión desde el agente.", + }, + who: { + title: "Quién lo pide", + fromHost: "Identificado por {host}", + registered: "Registrado en este Studio", + sendsBack: "Te devuelve a {host}", + thisComputer: "Te devuelve a una app de este equipo", + loopbackWarning: + "Todas las direcciones a las que este agente puede devolverte están en este equipo. Aprueba solo si iniciaste la conexión desde una app de aquí.", + stepUp: "Este agente ya está conectado y pide más. Lo que puede hacer hoy está marcado.", + }, + scopes: { + title: "Qué puede hacer", + description: + "Desmarca lo que no deba hacer. Tu rol decide el resto: lo que no concede queda fuera.", + legend: "Permisos", + notInRole: "{label} (no está en tu rol)", + narrowed: "{label}, solo para tu propio trabajo en tu rol ({scope})", + offline: + "Sigue conectado y renueva su acceso por sí mismo hasta que lo desconectes en la página Agentes.", + sites: + "{count, plural, one {Solo llega al sitio en el que trabajas.} many {Solo llega a los # sitios en los que trabajas.} other {Solo llega a los # sitios en los que trabajas.}}", + none: "Elige al menos un permiso.", + }, + mode: { + title: "Cómo trabaja", + description: "Con cuánta independencia puede trabajar el agente. Puedes cambiarlo más tarde.", + legend: "Modo del agente", + }, + approve: "Aprobar", + deny: "Rechazar", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts new file mode 100644 index 00000000..d8f7c202 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts @@ -0,0 +1,48 @@ +// Generated by `pnpm i18n:import` (ADR-0034): fa `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 28985d3e98482ebd +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "اتصال {client}", + description: + "یک عامل می‌خواهد در این Studio از طرف شما کار کند. پیش از تأیید، ببینید چه کارهایی مجاز است.", + invalid: { + title: "این درخواست قابل استفاده نیست", + description: + "منقضی شده، قبلاً پاسخ داده شده یا در مسیر تغییر کرده است. اتصال را دوباره از عامل شروع کنید.", + }, + who: { + title: "چه کسی درخواست می‌دهد", + fromHost: "شناسایی‌شده با {host}", + registered: "ثبت‌شده در این Studio", + sendsBack: "شما را به {host} برمی‌گرداند", + thisComputer: "شما را به برنامه‌ای در این رایانه برمی‌گرداند", + loopbackWarning: + "همهٔ نشانی‌هایی که این عامل می‌تواند شما را به آن‌ها برگرداند روی این رایانه است. فقط اگر اتصال را از برنامه‌ای در همین‌جا شروع کرده‌اید تأیید کنید.", + stepUp: + "این عامل از قبل متصل است و دسترسی بیشتری می‌خواهد. آنچه امروز مجاز است علامت خورده است.", + }, + scopes: { + title: "چه کارهایی مجاز است", + description: + "علامت هر چیزی را که نباید انجام دهد بردارید. بقیه را نقش شما تعیین می‌کند: آنچه نقش شما نمی‌دهد کنار گذاشته می‌شود.", + legend: "مجوزها", + notInRole: "{label} (در نقش شما نیست)", + narrowed: "{label}، فقط برای کار خودتان در نقش شما ({scope})", + offline: + "متصل می‌ماند و دسترسی‌اش را خودش تمدید می‌کند تا وقتی که در صفحهٔ عامل‌ها آن را قطع کنید.", + sites: + "{count, plural, one {فقط به سایتی که روی آن کار می‌کنید دسترسی دارد.} other {فقط به # سایتی که روی آن‌ها کار می‌کنید دسترسی دارد.}}", + none: "دست‌کم یک مجوز انتخاب کنید.", + }, + mode: { + title: "چگونه کار می‌کند", + description: "عامل تا چه اندازه مستقل کار کند. بعداً می‌توانید آن را تغییر دهید.", + legend: "حالت عامل", + }, + approve: "تأیید", + deny: "رد", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts new file mode 100644 index 00000000..f1c7b2b2 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts @@ -0,0 +1,49 @@ +// Generated by `pnpm i18n:import` (ADR-0034): fr `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: a8abd1ef16e87cf4 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Connecter {client}", + description: + "Un agent demande à travailler dans ce Studio pour vous. Vérifiez ce qu'il peut faire avant d'approuver.", + invalid: { + title: "Cette demande ne peut pas être utilisée", + description: + "Elle a expiré, a déjà reçu une réponse ou a été modifiée en chemin. Relancez la connexion depuis l'agent.", + }, + who: { + title: "Qui le demande", + fromHost: "Identifié par {host}", + registered: "Enregistré dans ce Studio", + sendsBack: "Vous renvoie vers {host}", + thisComputer: "Vous renvoie vers une application de cet ordinateur", + loopbackWarning: + "Toutes les adresses vers lesquelles cet agent peut vous renvoyer sont sur cet ordinateur. N'approuvez que si vous avez lancé la connexion depuis une application ici.", + stepUp: + "Cet agent est déjà connecté et demande davantage. Ce qu'il peut faire aujourd'hui est coché.", + }, + scopes: { + title: "Ce qu'il peut faire", + description: + "Décochez ce qu'il ne doit pas faire. Votre rôle décide du reste : ce qu'il n'accorde pas est laissé de côté.", + legend: "Autorisations", + notInRole: "{label} (hors de votre rôle)", + narrowed: "{label}, uniquement pour votre propre travail dans votre rôle ({scope})", + offline: + "Il reste connecté et renouvelle son accès lui-même jusqu'à ce que vous le déconnectiez sur la page Agents.", + sites: + "{count, plural, one {Il n'atteint que le site sur lequel vous travaillez.} many {Il n'atteint que les # sites sur lesquels vous travaillez.} other {Il n'atteint que les # sites sur lesquels vous travaillez.}}", + none: "Choisissez au moins une autorisation.", + }, + mode: { + title: "Comment il travaille", + description: + "Avec quelle autonomie l'agent peut travailler. Vous pourrez le modifier plus tard.", + legend: "Mode de l'agent", + }, + approve: "Approuver", + deny: "Refuser", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.he.ts b/apps/studio/src/i18n/lazy/oauthConsent.he.ts new file mode 100644 index 00000000..a9c1a931 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.he.ts @@ -0,0 +1,45 @@ +// Generated by `pnpm i18n:import` (ADR-0034): he `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: d8372bd1416a32fd +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "חיבור {client}", + description: "סוכן מבקש לעבוד ב-Studio הזה בשמך. כדאי לבדוק מה מותר לו לפני האישור.", + invalid: { + title: "אי אפשר להשתמש בבקשה הזו", + description: + "תוקפה פג, כבר ניתנה לה תשובה או שהיא שונתה בדרך. יש להתחיל את החיבור מחדש מהסוכן.", + }, + who: { + title: "מי מבקש", + fromHost: "מזוהה על ידי {host}", + registered: "רשום ב-Studio הזה", + sendsBack: "יחזיר אותך אל {host}", + thisComputer: "יחזיר אותך לאפליקציה במחשב הזה", + loopbackWarning: + "כל כתובת שהסוכן הזה יכול להחזיר אותך אליה נמצאת במחשב הזה. יש לאשר רק אם התחלת את החיבור מאפליקציה כאן.", + stepUp: "הסוכן הזה כבר מחובר ומבקש יותר. מה שמותר לו היום מסומן.", + }, + scopes: { + title: "מה מותר לו", + description: + "יש לבטל את הסימון של כל מה שאסור לו לעשות. השאר נקבע לפי התפקיד שלך: מה שהוא לא מעניק נשאר בחוץ.", + legend: "הרשאות", + notInRole: "{label} (לא בתפקיד שלך)", + narrowed: "{label}, רק לעבודה שלך בתפקיד שלך ({scope})", + offline: "הוא נשאר מחובר ומחדש את הגישה בעצמו עד שתנתק אותו בדף הסוכנים.", + sites: + "{count, plural, one {הוא מגיע רק לאתר שאת/ה עובד/ת עליו.} two {הוא מגיע רק לשני האתרים שאת/ה עובד/ת עליהם.} other {הוא מגיע רק ל-# האתרים שאת/ה עובד/ת עליהם.}}", + none: "יש לבחור לפחות הרשאה אחת.", + }, + mode: { + title: "איך הוא עובד", + description: "עד כמה הסוכן יכול לעבוד באופן עצמאי. אפשר לשנות זאת אחר כך.", + legend: "מצב הסוכן", + }, + approve: "אישור", + deny: "דחייה", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts new file mode 100644 index 00000000..ea550d41 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): hi `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: e1a45ae90aaa4022 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} कनेक्ट करें", + description: + "एक एजेंट इस Studio में आपकी ओर से काम करना चाहता है। मंज़ूरी देने से पहले देखें कि वह क्या कर सकता है।", + invalid: { + title: "यह अनुरोध इस्तेमाल नहीं किया जा सकता", + description: + "इसकी अवधि खत्म हो गई, इसका जवाब दिया जा चुका है या रास्ते में इसे बदला गया। एजेंट से कनेक्शन फिर से शुरू करें।", + }, + who: { + title: "कौन पूछ रहा है", + fromHost: "{host} से पहचाना गया", + registered: "इस Studio में रजिस्टर्ड", + sendsBack: "आपको {host} पर वापस भेजता है", + thisComputer: "आपको इस कंप्यूटर के किसी ऐप पर वापस भेजता है", + loopbackWarning: + "यह एजेंट आपको जिन भी पतों पर वापस भेज सकता है, वे सब इसी कंप्यूटर पर हैं। मंज़ूरी तभी दें जब आपने कनेक्शन यहीं के किसी ऐप से शुरू किया हो।", + stepUp: "यह एजेंट पहले से कनेक्टेड है और और ज़्यादा मांग रहा है। आज वह जो कर सकता है, वह चुना हुआ है।", + }, + scopes: { + title: "वह क्या कर सकता है", + description: + "जो उसे नहीं करना चाहिए, उसका निशान हटाएं। बाकी आपकी भूमिका तय करती है: जो वह नहीं देती, वह छोड़ दिया जाता है।", + legend: "अनुमतियां", + notInRole: "{label} (आपकी भूमिका में नहीं)", + narrowed: "{label}, आपकी भूमिका में सिर्फ़ आपके अपने काम के लिए ({scope})", + offline: + "वह जुड़ा रहता है और अपनी पहुंच खुद नवीनीकृत करता है, जब तक आप उसे एजेंट पेज पर डिस्कनेक्ट नहीं करते।", + sites: + "{count, plural, one {वह सिर्फ़ उस साइट तक पहुंचता है जिस पर आप काम करते हैं।} other {वह सिर्फ़ उन # साइटों तक पहुंचता है जिन पर आप काम करते हैं।}}", + none: "कम से कम एक अनुमति चुनें।", + }, + mode: { + title: "वह कैसे काम करता है", + description: "एजेंट कितनी स्वतंत्रता से काम कर सकता है। आप इसे बाद में बदल सकते हैं।", + legend: "एजेंट मोड", + }, + approve: "मंज़ूर करें", + deny: "अस्वीकार करें", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.id.ts b/apps/studio/src/i18n/lazy/oauthConsent.id.ts new file mode 100644 index 00000000..565a7305 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.id.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): id `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: de9dd0dedff197ca +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Hubungkan {client}", + description: + "Sebuah agen meminta untuk bekerja di Studio ini atas nama Anda. Periksa apa yang boleh dilakukannya sebelum menyetujui.", + invalid: { + title: "Permintaan ini tidak dapat digunakan", + description: + "Permintaan sudah kedaluwarsa, sudah dijawab, atau diubah di tengah jalan. Mulai lagi koneksi dari agen.", + }, + who: { + title: "Siapa yang meminta", + fromHost: "Diidentifikasi oleh {host}", + registered: "Terdaftar di Studio ini", + sendsBack: "Mengembalikan Anda ke {host}", + thisComputer: "Mengembalikan Anda ke aplikasi di komputer ini", + loopbackWarning: + "Setiap alamat tujuan agen ini mengembalikan Anda ada di komputer ini. Setujui hanya jika Anda memulai koneksi dari aplikasi di sini.", + stepUp: + "Agen ini sudah terhubung dan meminta lebih. Yang boleh dilakukannya saat ini sudah dicentang.", + }, + scopes: { + title: "Yang boleh dilakukannya", + description: + "Hapus centang pada hal yang tidak boleh dilakukannya. Sisanya ditentukan oleh peran Anda: yang tidak diberikan peran Anda tidak disertakan.", + legend: "Izin", + notInRole: "{label} (tidak ada di peran Anda)", + narrowed: "{label}, hanya untuk pekerjaan Anda sendiri dalam peran Anda ({scope})", + offline: + "Agen tetap terhubung dan memperbarui aksesnya sendiri sampai Anda memutuskannya di halaman Agen.", + sites: "{count, plural, other {Agen hanya menjangkau # situs yang Anda kerjakan.}}", + none: "Pilih minimal satu izin.", + }, + mode: { + title: "Cara kerjanya", + description: "Seberapa mandiri agen boleh bekerja. Anda dapat mengubahnya nanti.", + legend: "Mode agen", + }, + approve: "Setujui", + deny: "Tolak", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.it.ts b/apps/studio/src/i18n/lazy/oauthConsent.it.ts new file mode 100644 index 00000000..f546c341 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.it.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): it `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: a79e81130bc3f414 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Collega {client}", + description: + "Un agente chiede di lavorare in questo Studio per te. Controlla cosa può fare prima di approvare.", + invalid: { + title: "Questa richiesta non si può usare", + description: + "È scaduta, ha già avuto risposta o è stata modificata lungo la strada. Avvia di nuovo la connessione dall'agente.", + }, + who: { + title: "Chi lo chiede", + fromHost: "Identificato da {host}", + registered: "Registrato in questo Studio", + sendsBack: "Ti rimanda a {host}", + thisComputer: "Ti rimanda a un'app su questo computer", + loopbackWarning: + "Ogni indirizzo a cui questo agente può rimandarti è su questo computer. Approva solo se hai avviato la connessione da un'app qui.", + stepUp: "Questo agente è già collegato e chiede di più. Ciò che può fare oggi è spuntato.", + }, + scopes: { + title: "Cosa può fare", + description: + "Togli la spunta da ciò che non deve fare. Il resto lo decide il tuo ruolo: ciò che non concede resta fuori.", + legend: "Autorizzazioni", + notInRole: "{label} (non nel tuo ruolo)", + narrowed: "{label}, solo per il tuo lavoro nel tuo ruolo ({scope})", + offline: + "Resta collegato e rinnova da solo l'accesso finché non lo scolleghi nella pagina Agenti.", + sites: + "{count, plural, one {Raggiunge solo il sito su cui lavori.} many {Raggiunge solo i # siti su cui lavori.} other {Raggiunge solo i # siti su cui lavori.}}", + none: "Scegli almeno un'autorizzazione.", + }, + mode: { + title: "Come lavora", + description: "Quanto in autonomia può lavorare l'agente. Puoi cambiarlo in seguito.", + legend: "Modalità dell'agente", + }, + approve: "Approva", + deny: "Rifiuta", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts new file mode 100644 index 00000000..a07f96a6 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts @@ -0,0 +1,46 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ja `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 71ce1f23d0f2bda3 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} を接続", + description: + "エージェントがあなたの代わりにこの Studio で作業することを求めています。承認する前に、できることを確認してください。", + invalid: { + title: "このリクエストは使用できません", + description: + "期限切れか、すでに応答済みか、途中で変更されています。エージェントから接続をやり直してください。", + }, + who: { + title: "依頼元", + fromHost: "{host} で識別", + registered: "この Studio に登録済み", + sendsBack: "{host} に戻ります", + thisComputer: "このコンピューター上のアプリに戻ります", + loopbackWarning: + "このエージェントが戻す先のアドレスはすべてこのコンピューター上にあります。ここにあるアプリから接続を始めた場合にのみ承認してください。", + stepUp: + "このエージェントはすでに接続済みで、さらに権限を求めています。現在できることにはチェックが入っています。", + }, + scopes: { + title: "できること", + description: + "させたくないことのチェックを外してください。残りはあなたのロールで決まり、ロールにないものは除外されます。", + legend: "権限", + notInRole: "{label}(あなたのロールにはありません)", + narrowed: "{label}(あなたのロールでは自分の作業のみ: {scope})", + offline: "エージェントページで切断するまで接続したままで、アクセスを自動で更新します。", + sites: "{count, plural, other {あなたが担当する # 件のサイトにのみアクセスします。}}", + none: "権限を 1 つ以上選んでください。", + }, + mode: { + title: "動作のしかた", + description: "エージェントがどの程度自律して作業できるか。後から変更できます。", + legend: "エージェントモード", + }, + approve: "承認", + deny: "拒否", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts new file mode 100644 index 00000000..c52d782c --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ko `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 2f739dfc56ae74cf +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} 연결", + description: + "에이전트가 이 Studio에서 나를 대신해 일하겠다고 요청합니다. 승인하기 전에 할 수 있는 일을 확인하세요.", + invalid: { + title: "이 요청은 사용할 수 없습니다", + description: + "만료되었거나 이미 응답했거나 도중에 변경되었습니다. 에이전트에서 연결을 다시 시작하세요.", + }, + who: { + title: "요청한 곳", + fromHost: "{host}(으)로 식별됨", + registered: "이 Studio에 등록됨", + sendsBack: "{host}(으)로 돌아갑니다", + thisComputer: "이 컴퓨터의 앱으로 돌아갑니다", + loopbackWarning: + "이 에이전트가 돌려보낼 수 있는 주소는 모두 이 컴퓨터에 있습니다. 이 컴퓨터의 앱에서 연결을 시작한 경우에만 승인하세요.", + stepUp: + "이 에이전트는 이미 연결되어 있으며 더 많은 권한을 요청합니다. 지금 할 수 있는 일은 체크되어 있습니다.", + }, + scopes: { + title: "할 수 있는 일", + description: + "하지 말아야 할 일은 체크를 해제하세요. 나머지는 내 역할이 정하며, 역할이 주지 않는 것은 제외됩니다.", + legend: "권한", + notInRole: "{label} (내 역할에 없음)", + narrowed: "{label}, 내 역할에서 내 작업에만 ({scope})", + offline: + "에이전트 페이지에서 연결을 끊을 때까지 연결된 상태로 남아 접근 권한을 스스로 갱신합니다.", + sites: "{count, plural, other {내가 작업하는 사이트 #개에만 접근합니다.}}", + none: "권한을 하나 이상 선택하세요.", + }, + mode: { + title: "작동 방식", + description: "에이전트가 얼마나 독립적으로 일할 수 있는지. 나중에 바꿀 수 있습니다.", + legend: "에이전트 모드", + }, + approve: "승인", + deny: "거부", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts new file mode 100644 index 00000000..e9dd1dcd --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): nl `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 3b961d3076828d00 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} verbinden", + description: + "Een agent vraagt om voor je in deze Studio te werken. Controleer wat hij mag voordat je goedkeurt.", + invalid: { + title: "Dit verzoek kan niet worden gebruikt", + description: + "Het is verlopen, al beantwoord of onderweg gewijzigd. Start de verbinding opnieuw vanuit de agent.", + }, + who: { + title: "Wie het vraagt", + fromHost: "Geïdentificeerd door {host}", + registered: "Geregistreerd bij deze Studio", + sendsBack: "Stuurt je terug naar {host}", + thisComputer: "Stuurt je terug naar een app op deze computer", + loopbackWarning: + "Elk adres waarnaar deze agent je kan terugsturen, staat op deze computer. Keur alleen goed als je de verbinding vanuit een app hier hebt gestart.", + stepUp: "Deze agent is al verbonden en vraagt om meer. Wat hij nu mag, is aangevinkt.", + }, + scopes: { + title: "Wat hij mag", + description: + "Vink uit wat hij niet mag doen. Je rol bepaalt de rest: wat die niet geeft, valt weg.", + legend: "Rechten", + notInRole: "{label} (niet in je rol)", + narrowed: "{label}, alleen voor je eigen werk in je rol ({scope})", + offline: + "Hij blijft verbonden en vernieuwt zijn toegang zelf tot je hem op de pagina Agents loskoppelt.", + sites: + "{count, plural, one {Hij bereikt alleen de site waaraan je werkt.} other {Hij bereikt alleen de # sites waaraan je werkt.}}", + none: "Kies minstens één recht.", + }, + mode: { + title: "Hoe hij werkt", + description: "Hoe zelfstandig de agent mag werken. Je kunt dit later wijzigen.", + legend: "Agentmodus", + }, + approve: "Goedkeuren", + deny: "Weigeren", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts new file mode 100644 index 00000000..27471c5d --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts @@ -0,0 +1,48 @@ +// Generated by `pnpm i18n:import` (ADR-0034): pl `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 50204e4651a82668 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Połącz {client}", + description: + "Agent prosi o pracę w tym Studio w twoim imieniu. Sprawdź, co może robić, zanim zatwierdzisz.", + invalid: { + title: "Tego żądania nie można użyć", + description: + "Wygasło, już na nie odpowiedziano albo zostało zmienione po drodze. Rozpocznij połączenie ponownie w agencie.", + }, + who: { + title: "Kto prosi", + fromHost: "Identyfikowany przez {host}", + registered: "Zarejestrowany w tym Studio", + sendsBack: "Odeśle cię do {host}", + thisComputer: "Odeśle cię do aplikacji na tym komputerze", + loopbackWarning: + "Każdy adres, do którego ten agent może cię odesłać, jest na tym komputerze. Zatwierdź tylko wtedy, gdy połączenie rozpoczęła aplikacja na tym komputerze.", + stepUp: + "Ten agent jest już połączony i prosi o więcej. To, co może dziś robić, jest zaznaczone.", + }, + scopes: { + title: "Co może robić", + description: + "Odznacz to, czego nie powinien robić. Resztę decyduje twoja rola: czego nie daje, zostaje pominięte.", + legend: "Uprawnienia", + notInRole: "{label} (poza twoją rolą)", + narrowed: "{label}, tylko do twojej własnej pracy w twojej roli ({scope})", + offline: + "Pozostaje połączony i sam odnawia dostęp, dopóki nie odłączysz go na stronie Agenci.", + sites: + "{count, plural, one {Ma dostęp tylko do witryny, nad którą pracujesz.} few {Ma dostęp tylko do # witryn, nad którymi pracujesz.} many {Ma dostęp tylko do # witryn, nad którymi pracujesz.} other {Ma dostęp tylko do # witryny, nad którymi pracujesz.}}", + none: "Wybierz co najmniej jedno uprawnienie.", + }, + mode: { + title: "Jak pracuje", + description: "Jak samodzielnie agent może pracować. Możesz to później zmienić.", + legend: "Tryb agenta", + }, + approve: "Zatwierdź", + deny: "Odrzuć", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts new file mode 100644 index 00000000..0931ac0f --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): pt-BR `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 9196a55f7b64f7db +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Conectar {client}", + description: + "Um agente pede para trabalhar neste Studio por você. Confira o que ele pode fazer antes de aprovar.", + invalid: { + title: "Esta solicitação não pode ser usada", + description: + "Ela expirou, já foi respondida ou foi alterada no caminho. Inicie a conexão de novo pelo agente.", + }, + who: { + title: "Quem está pedindo", + fromHost: "Identificado por {host}", + registered: "Registrado neste Studio", + sendsBack: "Leva você de volta para {host}", + thisComputer: "Leva você de volta para um app neste computador", + loopbackWarning: + "Todos os endereços para os quais este agente pode levar você de volta estão neste computador. Aprove só se você iniciou a conexão por um app daqui.", + stepUp: "Este agente já está conectado e pede mais. O que ele pode fazer hoje está marcado.", + }, + scopes: { + title: "O que ele pode fazer", + description: + "Desmarque o que ele não deve fazer. Sua função decide o resto: o que ela não concede fica de fora.", + legend: "Permissões", + notInRole: "{label} (fora da sua função)", + narrowed: "{label}, só para o seu próprio trabalho na sua função ({scope})", + offline: + "Ele continua conectado e renova o acesso sozinho até você desconectá-lo na página Agentes.", + sites: + "{count, plural, one {Ele só alcança o site em que você trabalha.} many {Ele só alcança os # sites em que você trabalha.} other {Ele só alcança os # sites em que você trabalha.}}", + none: "Escolha pelo menos uma permissão.", + }, + mode: { + title: "Como ele trabalha", + description: "Com quanta independência o agente pode trabalhar. Você pode mudar isso depois.", + legend: "Modo do agente", + }, + approve: "Aprovar", + deny: "Recusar", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts new file mode 100644 index 00000000..c07be78c --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): ru `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 43ea63682e1ce2ec +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Подключить {client}", + description: + "Агент просит работать в этом Studio от вашего имени. Проверьте, что ему можно, прежде чем разрешить.", + invalid: { + title: "Этот запрос нельзя использовать", + description: + "Он истёк, на него уже ответили или его изменили по пути. Начните подключение в агенте заново.", + }, + who: { + title: "Кто просит", + fromHost: "Идентифицирован через {host}", + registered: "Зарегистрирован в этом Studio", + sendsBack: "Вернёт вас на {host}", + thisComputer: "Вернёт вас в приложение на этом компьютере", + loopbackWarning: + "Все адреса, на которые этот агент может вас вернуть, находятся на этом компьютере. Разрешайте, только если подключение начато из приложения здесь.", + stepUp: "Этот агент уже подключён и просит больше. То, что ему можно сейчас, отмечено.", + }, + scopes: { + title: "Что ему можно", + description: + "Снимите отметку с того, что ему делать нельзя. Остальное решает ваша роль: то, чего она не даёт, исключено.", + legend: "Разрешения", + notInRole: "{label} (нет в вашей роли)", + narrowed: "{label}, только для вашей собственной работы в вашей роли ({scope})", + offline: + "Он остаётся подключённым и сам продлевает доступ, пока вы не отключите его на странице «Агенты».", + sites: + "{count, plural, one {Он видит только # сайт, с которым вы работаете.} few {Он видит только # сайта, с которыми вы работаете.} many {Он видит только # сайтов, с которыми вы работаете.} other {Он видит только # сайта, с которыми вы работаете.}}", + none: "Выберите хотя бы одно разрешение.", + }, + mode: { + title: "Как он работает", + description: "Насколько самостоятельно может работать агент. Это можно изменить позже.", + legend: "Режим агента", + }, + approve: "Разрешить", + deny: "Отклонить", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts new file mode 100644 index 00000000..f9f44955 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): tr `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: 2bef83f2b61322ba +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "{client} bağla", + description: + "Bir agent bu Studio'da senin adına çalışmak istiyor. Onaylamadan önce neler yapabileceğine bak.", + invalid: { + title: "Bu istek kullanılamaz", + description: + "Süresi doldu, zaten yanıtlandı ya da yolda değiştirildi. Bağlantıyı agent'tan yeniden başlat.", + }, + who: { + title: "Kim istiyor", + fromHost: "{host} ile tanımlanıyor", + registered: "Bu Studio'ya kayıtlı", + sendsBack: "Seni {host} adresine geri gönderir", + thisComputer: "Seni bu bilgisayardaki bir uygulamaya geri gönderir", + loopbackWarning: + "Bu agent'ın seni geri gönderebileceği her adres bu bilgisayarda. Yalnızca bağlantıyı buradaki bir uygulamadan başlattıysan onayla.", + stepUp: "Bu agent zaten bağlı ve daha fazlasını istiyor. Bugün yapabildikleri işaretli.", + }, + scopes: { + title: "Neler yapabilir", + description: + "Yapmaması gerekenlerin işaretini kaldır. Gerisine rolün karar verir: rolünün vermediği şeyler dışarıda kalır.", + legend: "İzinler", + notInRole: "{label} (rolünde yok)", + narrowed: "{label}, rolünde yalnızca kendi işin için ({scope})", + offline: + "Sen Agent'lar sayfasından bağlantısını kesene kadar bağlı kalır ve erişimini kendisi yeniler.", + sites: + "{count, plural, one {Yalnızca üzerinde çalıştığın siteye erişir.} other {Yalnızca üzerinde çalıştığın {count, number} siteye erişir.}}", + none: "En az bir izin seç.", + }, + mode: { + title: "Nasıl çalışır", + description: "Agent'ın ne kadar bağımsız çalışabileceği. Sonra değiştirebilirsin.", + legend: "Agent modu", + }, + approve: "Onayla", + deny: "Reddet", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts new file mode 100644 index 00000000..4bcb5898 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts @@ -0,0 +1,47 @@ +// Generated by `pnpm i18n:import` (ADR-0034): uk `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: cbcee1aeb800d486 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "Під'єднати {client}", + description: + "Агент просить працювати в цьому Studio від вашого імені. Перевірте, що йому можна, перш ніж дозволити.", + invalid: { + title: "Цей запит не можна використати", + description: + "Він сплив, на нього вже відповіли або його змінили дорогою. Почніть під'єднання в агенті знову.", + }, + who: { + title: "Хто просить", + fromHost: "Ідентифікований через {host}", + registered: "Зареєстрований у цьому Studio", + sendsBack: "Поверне вас на {host}", + thisComputer: "Поверне вас у застосунок на цьому комп'ютері", + loopbackWarning: + "Усі адреси, на які цей агент може вас повернути, на цьому комп'ютері. Дозволяйте, лише якщо під'єднання почато із застосунку тут.", + stepUp: "Цей агент уже під'єднаний і просить більше. Те, що йому можна зараз, позначено.", + }, + scopes: { + title: "Що йому можна", + description: + "Зніміть позначку з того, чого йому не можна робити. Решту вирішує ваша роль: те, чого вона не дає, виключено.", + legend: "Дозволи", + notInRole: "{label} (немає у вашій ролі)", + narrowed: "{label}, лише для вашої власної роботи у вашій ролі ({scope})", + offline: + "Він лишається під'єднаним і сам поновлює доступ, доки ви не від'єднаєте його на сторінці «Агенти».", + sites: + "{count, plural, one {Він бачить лише # сайт, з яким ви працюєте.} few {Він бачить лише # сайти, з якими ви працюєте.} many {Він бачить лише # сайтів, з якими ви працюєте.} other {Він бачить лише # сайту, з якими ви працюєте.}}", + none: "Виберіть принаймні один дозвіл.", + }, + mode: { + title: "Як він працює", + description: "Наскільки самостійно може працювати агент. Це можна змінити пізніше.", + legend: "Режим агента", + }, + approve: "Дозволити", + deny: "Відхилити", + }, +}; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts new file mode 100644 index 00000000..69fc14f1 --- /dev/null +++ b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts @@ -0,0 +1,42 @@ +// Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `oauthConsent.*` messages. +// Do not edit: change the translation file and import it; only English is edited here. +// hash: b940b3fd25633015 +import type { NamespaceMessages } from "../namespaces.ts"; + +export const messages: NamespaceMessages<"oauthConsent"> = { + oauthConsent: { + title: "连接 {client}", + description: "有个代理请求代表你在这个 Studio 中工作。批准前请先查看它可以做什么。", + invalid: { + title: "无法使用此请求", + description: "它已过期、已被处理,或在传递途中被更改。请从代理重新发起连接。", + }, + who: { + title: "谁在请求", + fromHost: "由 {host} 标识", + registered: "已在此 Studio 注册", + sendsBack: "会把你送回 {host}", + thisComputer: "会把你送回这台电脑上的应用", + loopbackWarning: + "这个代理能把你送回的所有地址都在这台电脑上。只有当你是从这里的应用发起连接时才批准。", + stepUp: "这个代理已经连接,现在请求更多权限。它目前能做的已勾选。", + }, + scopes: { + title: "它可以做什么", + description: "取消勾选它不应做的事。其余由你的角色决定:角色不授予的会被排除。", + legend: "权限", + notInRole: "{label}(不在你的角色中)", + narrowed: "{label},在你的角色中仅限你自己的工作({scope})", + offline: "它会保持连接并自行续期访问权限,直到你在“代理”页面断开它。", + sites: "{count, plural, other {它只能访问你参与的 # 个站点。}}", + none: "请至少选择一项权限。", + }, + mode: { + title: "工作方式", + description: "代理可以多大程度上独立工作。之后可以更改。", + legend: "代理模式", + }, + approve: "批准", + deny: "拒绝", + }, +}; diff --git a/apps/studio/src/i18n/namespaces.ts b/apps/studio/src/i18n/namespaces.ts index 899b0f98..8916d462 100644 --- a/apps/studio/src/i18n/namespaces.ts +++ b/apps/studio/src/i18n/namespaces.ts @@ -25,6 +25,7 @@ import type { messages as fields } from "./lazy/fields.en.ts"; import type { messages as listings } from "./lazy/listings.en.ts"; import type { messages as media } from "./lazy/media.en.ts"; import type { messages as menus } from "./lazy/menus.en.ts"; +import type { messages as oauthConsent } from "./lazy/oauthConsent.en.ts"; import type { messages as readers } from "./lazy/readers.en.ts"; import type { messages as scheduleSettings } from "./lazy/scheduleSettings.en.ts"; import type { messages as schedules } from "./lazy/schedules.en.ts"; @@ -47,6 +48,7 @@ export interface NamespaceCatalogs { listings: DeepString<(typeof listings)["listings"]>; media: DeepString<(typeof media)["media"]>; menus: DeepString<(typeof menus)["menus"]>; + oauthConsent: DeepString<(typeof oauthConsent)["oauthConsent"]>; readers: DeepString<(typeof readers)["readers"]>; scheduleSettings: DeepString<(typeof scheduleSettings)["scheduleSettings"]>; schedules: DeepString<(typeof schedules)["schedules"]>; diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index a6873017..64c345af 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 90b625c9682724a2 +// hash: 16c404ee0eeeafc4 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1268,6 +1268,7 @@ export const messages: Messages = { members: "Team en rollen", agentKeys: "Agentsleutels", mcp: "MCP-server", + mcpOauth: "Agents verbinden via URL", activity: "Activiteitenlog", previewLinks: "Voorbeeldlinks", deployTransfer: "Deployen en overzetten", @@ -1279,6 +1280,8 @@ export const messages: Messages = { "Mensen komen erbij via een uitnodigingslink en werken in een rol: eigenaar, beheerder, redacteur, auteur of bijdrager.", agentKeys: "Agents en CI-jobs loggen in met sleutels die jij maakt.", mcp: "Agents verbinden via /mcp.", + mcpOauth: + "Claude, ChatGPT en andere MCP-clients verbinden met alleen het /mcp-adres; jij keurt elke verbinding goed.", activity: "Elke toolaanroep wordt per site vastgelegd.", previewLinks: "Ondertekende links die concepten op de gedeployde site tonen.", deployTransfer: "cloudhal push en pull kunnen deze server bereiken.", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index c662e6c6..b1a2a331 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 384d9242a8e6de69 +// hash: d13a8791449088fd import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1250,6 +1250,7 @@ export const messages: Messages = { members: "Zespół i role", agentKeys: "Klucze agentów", mcp: "Serwer MCP", + mcpOauth: "Łączenie agentów przez URL", activity: "Dziennik aktywności", previewLinks: "Linki podglądu", deployTransfer: "Wdrażanie i transfer", @@ -1261,6 +1262,8 @@ export const messages: Messages = { "Ludzie dołączają przez link z zaproszeniem i pracują w roli: Właściciel, Administrator, Redaktor, Autor lub Współtwórca.", agentKeys: "Agenci i zadania CI logują się kluczami, które tworzysz.", mcp: "Agenci łączą się pod /mcp.", + mcpOauth: + "Claude, ChatGPT i inni klienci MCP łączą się samym adresem /mcp; każde połączenie zatwierdzasz ty.", activity: "Każde wywołanie narzędzia jest zapisywane dla każdej witryny.", previewLinks: "Podpisane linki pokazujące szkice na wdrożonej witrynie.", deployTransfer: "cloudhal push i pull mają dostęp do tego serwera.", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index 63410655..e360fbb5 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f5164a39ad56cc1e +// hash: 95432c1c202d09be import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1256,6 +1256,7 @@ export const messages: Messages = { members: "Equipe e funções", agentKeys: "Chaves de agente", mcp: "Servidor MCP", + mcpOauth: "Conectar agentes por URL", activity: "Log de atividades", previewLinks: "Links de pré-visualização", deployTransfer: "Implantação e transferência", @@ -1267,6 +1268,8 @@ export const messages: Messages = { "As pessoas entram por link de convite e trabalham em uma função: Proprietário, Administrador, Editor, Autor ou Colaborador.", agentKeys: "Agentes e jobs de CI entram com chaves que você cria.", mcp: "Os agentes se conectam em /mcp.", + mcpOauth: + "Claude, ChatGPT e outros clientes MCP se conectam só com o endereço /mcp; você aprova cada conexão.", activity: "Toda chamada de ferramenta é registrada por site.", previewLinks: "Links assinados que mostram rascunhos no site implantado.", deployTransfer: "cloudhal push e pull conseguem acessar este servidor.", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index 893041de..623bffe2 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4172d91f9afefbde +// hash: 9b2c14d813a0667c import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1272,6 +1272,7 @@ export const messages: Messages = { members: "Команда и роли", agentKeys: "Ключи агентов", mcp: "MCP-сервер", + mcpOauth: "Подключение агентов по URL", activity: "Журнал активности", previewLinks: "Ссылки предпросмотра", deployTransfer: "Развёртывание и перенос", @@ -1283,6 +1284,8 @@ export const messages: Messages = { "Люди присоединяются по ссылке-приглашению и работают в одной из ролей: владелец, администратор, редактор, автор или соавтор.", agentKeys: "Агенты и задачи CI входят с помощью созданных вами ключей.", mcp: "Агенты подключаются по адресу /mcp.", + mcpOauth: + "Claude, ChatGPT и другие MCP-клиенты подключаются только по адресу /mcp; каждое подключение подтверждаете вы.", activity: "Каждый вызов инструмента записывается для каждого сайта.", previewLinks: "Подписанные ссылки, показывающие черновики на развёрнутом сайте.", deployTransfer: "cloudhal push и pull могут подключаться к этому серверу.", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 926dcf46..9c199c33 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 5386070af29019ab +// hash: 1aa67e6b30d91685 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1248,6 +1248,7 @@ export const messages: Messages = { members: "Ekip ve roller", agentKeys: "Agent anahtarları", mcp: "MCP sunucusu", + mcpOauth: "Agent'ları URL ile bağla", activity: "Etkinlik kaydı", previewLinks: "Önizleme bağlantıları", deployTransfer: "Dağıtım ve aktarım", @@ -1259,6 +1260,8 @@ export const messages: Messages = { "Kişiler davet bağlantısıyla katılır ve bir rolle çalışır: Sahip, Yönetici, Editör, Yazar ya da Katkıcı.", agentKeys: "Agent'lar ve CI işleri oluşturduğun anahtarlarla giriş yapar.", mcp: "Agent'lar /mcp adresinden bağlanır.", + mcpOauth: + "Claude, ChatGPT ve diğer MCP istemcileri yalnızca /mcp adresiyle bağlanır; her bağlantıyı sen onaylarsın.", activity: "Her araç çağrısı site bazında kaydedilir.", previewLinks: "Dağıtılmış sitede taslakları gösteren imzalı bağlantılar.", deployTransfer: "cloudhal push ve pull bu sunucuya ulaşabilir.", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 5c4b09a4..291a91fe 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 641cc646fa198d5a +// hash: 9b0ab250b5bc85c2 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1258,6 +1258,7 @@ export const messages: Messages = { members: "Команда й ролі", agentKeys: "Ключі агентів", mcp: "Сервер MCP", + mcpOauth: "Під'єднання агентів за URL", activity: "Журнал активності", previewLinks: "Посилання для перегляду", deployTransfer: "Розгортання й перенесення", @@ -1269,6 +1270,8 @@ export const messages: Messages = { "Люди приєднуються за посиланням-запрошенням і працюють у ролі: власник, адміністратор, редактор, автор або дописувач.", agentKeys: "Агенти та завдання CI входять за допомогою створених вами ключів.", mcp: "Агенти підключаються за адресою /mcp.", + mcpOauth: + "Claude, ChatGPT та інші MCP-клієнти під'єднуються лише за адресою /mcp; кожне під'єднання підтверджуєте ви.", activity: "Кожен виклик інструмента записується для кожного сайту.", previewLinks: "Підписані посилання, що показують чернетки на розгорнутому сайті.", deployTransfer: "cloudhal push і pull можуть звертатися до цього сервера.", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index e0093709..bab392e6 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 4fb9fabfcc51949d +// hash: 62ed1a1f5398d8d1 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1187,6 +1187,7 @@ export const messages: Messages = { members: "团队和角色", agentKeys: "智能体密钥", mcp: "MCP 服务器", + mcpOauth: "通过 URL 连接代理", activity: "动态日志", previewLinks: "预览链接", deployTransfer: "部署和传输", @@ -1197,6 +1198,7 @@ export const messages: Messages = { members: "成员通过邀请链接加入,并担任某个角色:所有者、管理员、编辑者、作者或投稿者。", agentKeys: "智能体和 CI 任务使用你创建的密钥登录。", mcp: "智能体在 /mcp 连接。", + mcpOauth: "Claude、ChatGPT 等 MCP 客户端只需 /mcp 地址即可连接;每次连接都由你批准。", activity: "每次工具调用都按站点记录。", previewLinks: "在已部署站点上显示草稿的签名链接。", deployTransfer: "cloudhal push 和 pull 可以访问此服务器。", diff --git a/apps/studio/src/lib/api.ts b/apps/studio/src/lib/api.ts index e43fffb3..16d96329 100644 --- a/apps/studio/src/lib/api.ts +++ b/apps/studio/src/lib/api.ts @@ -4,6 +4,7 @@ * calls stay the same everywhere. */ +export type { AgentGrantSummary, ConsentInfo, ConsentScope } from "./api/agent-grants.ts"; export type { ActivityPage, AgentKeyElevation, @@ -212,6 +213,7 @@ export type { } from "./api/team.ts"; export type { TranslationRow } from "./api/translations.ts"; +import { agentGrantsApi } from "./api/agent-grants.ts"; import { agentsApi } from "./api/agents.ts"; import { aiApi } from "./api/ai.ts"; import { analyticsApi } from "./api/analytics.ts"; @@ -260,6 +262,7 @@ export const api = { migrations: taxonomyApi.migrations, activity: agentsApi.activity, agentKeys: agentsApi.agentKeys, + agentGrants: agentGrantsApi, assets: mediaApi, analytics: analyticsApi, comments: commentsApi, diff --git a/apps/studio/src/lib/api/agent-grants.ts b/apps/studio/src/lib/api/agent-grants.ts new file mode 100644 index 00000000..76ad4a05 --- /dev/null +++ b/apps/studio/src/lib/api/agent-grants.ts @@ -0,0 +1,55 @@ +/** + * RFC-0044 §4: OAuth connections of remote MCP clients (`/api/agent-grants`) and the consent + * screen's API. Consent is given here, never at the provider's own endpoint: the server narrows + * the scopes by the member's role, caps the mode and records the grant. + */ +import type { AgentScope, KeyMode } from "./agents.ts"; +import { json, listOf, request } from "./http.ts"; + +/** One requested scope and what the member's role turns it into (`null`: not in the role). */ +export interface ConsentScope { + requested: AgentScope; + granted: AgentScope | null; +} + +/** What the consent screen shows (`GET /api/agent-grants/consent`). */ +export interface ConsentInfo { + client: { id: string; name: string; host?: string }; + redirect: { host: string; loopback: boolean }; + loopbackOnly: boolean; + scopes: ConsentScope[]; + offlineAccess: boolean; + modes: KeyMode[]; + defaultMode: KeyMode; + existing?: { scopes: AgentScope[]; mode: KeyMode }; + sites?: string[]; +} + +export interface AgentGrantSummary { + id: string; + agentId: string; + clientId: string; + clientName: string; + scopes: AgentScope[]; + effectiveScopes: AgentScope[]; + mode: KeyMode; + effectiveMode: KeyMode; + createdAt: string; + lastUsedAt: string | null; + owner?: { userId: string; name: string | null }; +} + +export const agentGrantsApi = { + /** `signed`: the signed authorization request, the consent page's own query string. */ + consentInfo: (signed: string) => + request(`/api/agent-grants/consent?request=${encodeURIComponent(signed)}`), + /** `{redirect}`: where the browser goes next (the client, with a code or `access_denied`). */ + consent: (input: { request: string; accept: boolean; scopes: AgentScope[]; mode: KeyMode }) => + request<{ redirect: string }>("/api/agent-grants/consent", { + method: "POST", + ...json(input), + }), + list: () => request("/api/agent-grants").then(listOf), + revoke: (id: string) => + request(`/api/agent-grants/${encodeURIComponent(id)}`, { method: "DELETE" }), +}; diff --git a/apps/studio/src/lib/api/team.ts b/apps/studio/src/lib/api/team.ts index 32af4ffb..619f79e7 100644 --- a/apps/studio/src/lib/api/team.ts +++ b/apps/studio/src/lib/api/team.ts @@ -14,6 +14,8 @@ export interface InstanceInfo { features: { agentKeys: boolean; mcp: boolean; + /** RFC-0044: remote MCP clients connect by URL through OAuth. Older servers omit it. */ + mcpOauth?: boolean; activity: boolean; previewLinks: boolean; deployTransfer: boolean; diff --git a/apps/studio/src/router.tsx b/apps/studio/src/router.tsx index fb02546d..83b3a135 100644 --- a/apps/studio/src/router.tsx +++ b/apps/studio/src/router.tsx @@ -79,6 +79,10 @@ const CommenterPage = lazyRouteComponent( "CommenterPage", ); const LoginPage = lazyRouteComponent(() => import("./routes/LoginPage.tsx"), "LoginPage"); +const OAuthConsentPage = lazyRouteComponent( + () => withMessages(import("./routes/OAuthConsentPage.tsx"), ["oauthConsent"]), + "OAuthConsentPage", +); const InvitePage = lazyRouteComponent(() => import("./routes/InvitePage.tsx"), "InvitePage"); const ResetPasswordPage = lazyRouteComponent( () => import("./routes/ResetPasswordPage.tsx"), @@ -236,6 +240,12 @@ const accountRoute = createRoute({ beforeLoad: requireSession, component: AccountShell, }); +/** RFC-0044 §4: an MCP client's authorization request, after sign-in (the provider sends it here). */ +const oauthConsentRoute = createRoute({ + getParentRoute: () => accountRoute, + path: "/oauth/consent", + component: OAuthConsentPage, +}); const sitesRoute = createRoute({ getParentRoute: () => accountRoute, path: "/sites", @@ -525,6 +535,7 @@ const routeTree = rootRoute.addChildren([ forgotPasswordRoute, indexRoute, accountRoute.addChildren([ + oauthConsentRoute, sitesRoute, projectRoute, agentsRoute, diff --git a/apps/studio/src/routes/LoginPage.tsx b/apps/studio/src/routes/LoginPage.tsx index b2d77643..7eaf731d 100644 --- a/apps/studio/src/routes/LoginPage.tsx +++ b/apps/studio/src/routes/LoginPage.tsx @@ -55,10 +55,27 @@ export function takeSetupToken(): string | undefined { } } +/** + * RFC-0044 §4: an MCP client's authorization request sends a signed-out member here with the + * provider's signed request as the query string (`client_id`, …, `sig`). Signing in posts it + * along (`oauth_query`), and the provider answers where to go next: the consent screen. + */ +export function oauthRequest(): string | undefined { + try { + const params = new URLSearchParams(window.location.search); + return params.has("sig") && params.has("client_id") + ? window.location.search.slice(1) + : undefined; + } catch { + return undefined; + } +} + export function LoginPage() { const t = useT(); const navigate = useNavigate(); const [setupToken] = useState(takeSetupToken); + const [authorization] = useState(oauthRequest); const [mode, setMode] = useState<"signin" | "signup">(setupToken ? "signup" : "signin"); useEffect(() => { if (setupToken) setMode("signup"); @@ -79,7 +96,11 @@ export function LoginPage() { { name, email, password }, setupToken ? { headers: { "x-cloudhal-setup-token": setupToken } } : undefined, ) - : await authClient.signIn.email({ email, password }); + : await authClient.signIn.email({ + email, + password, + ...(authorization ? { oauth_query: authorization } : {}), + } as { email: string; password: string }); setBusy(false); if (result.error) { setError(result.error.message ?? t("auth.failed")); @@ -90,6 +111,11 @@ export function LoginPage() { } catch { // storage unavailable } + const next = (result.data as { url?: unknown } | null)?.url; + if (authorization && typeof next === "string") { + window.location.assign(next); + return; + } await navigate({ to: "/sites" }); } diff --git a/apps/studio/src/routes/OAuthConsentPage.tsx b/apps/studio/src/routes/OAuthConsentPage.tsx new file mode 100644 index 00000000..f569555f --- /dev/null +++ b/apps/studio/src/routes/OAuthConsentPage.tsx @@ -0,0 +1,213 @@ +/** + * `/oauth/consent` (RFC-0044 §4): where a remote MCP client's authorization request lands once + * the member is signed in. It shows who is asking (the client, the host it is identified by, the + * host it sends the browser back to), what it may do (the requested scopes, each as the member's + * role turns it; what the role does not give is shown disabled and left out) and the agent mode + * (Decision 4). Approve and Deny go to `/api/agent-grants/consent`, which answers where the browser + * goes next. The page's own query string is the provider's signed request, read as it came. + */ +import { Banner, Button, Checkbox, InsetPanel, PageLayout, SectionCard, Text } from "@cloudhal/ui"; +import { + Desktop, + Globe, + PlugsConnected, + ShieldCheck, + SlidersHorizontal, +} from "@phosphor-icons/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { AutoModeConfirm, ModePicker } from "../components/AgentModes.tsx"; +import { useT } from "../i18n/index.tsx"; +import { type AgentScope, api, type ConsentInfo, type KeyMode } from "../lib/api.ts"; +import { apiErrorText } from "../lib/api-errors.ts"; + +/** The signed authorization request, untouched (re-encoding it would break the signature). */ +function signedRequest(): string { + return typeof window === "undefined" ? "" : window.location.search.slice(1); +} + +export function OAuthConsentPage() { + const t = useT(); + const c = useT("oauthConsent"); + const [request] = useState(signedRequest); + const info = useQuery({ + queryKey: ["oauth-consent", request], + queryFn: () => api.agentGrants.consentInfo(request), + enabled: request.length > 0, + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }); + const name = info.data?.client.name ?? ""; + return ( + } + title={c("oauthConsent.title", { client: name })} + description={c("oauthConsent.description")} + loading={info.isPending && request.length > 0} + width="narrow" + > + {info.data ? ( + + ) : ( + (info.isError || request.length === 0) && ( + + ) + )} + + ); +} + +function ConsentForm({ request, info }: { request: string; info: ConsentInfo }) { + const t = useT(); + const c = useT("oauthConsent"); + const grantable = info.scopes.filter((s) => s.granted !== null).map((s) => s.requested); + const [chosen, setChosen] = useState(grantable); + const [mode, setMode] = useState(info.defaultMode); + // Auto publishes without a person: explicit consent first, as for a key (DESIGN.md §2g). + const [autoConfirmed, setAutoConfirmed] = useState(false); + const [submitted, setSubmitted] = useState(false); + const needsConsent = mode === "auto" && info.existing?.mode !== "auto"; + // The scope's description, as the agent-key screens word it (`*` never reaches a token). + const label = (scope: AgentScope) => + scope === "*" ? t("agents.fullAccess") : t(`agents.scopes.${scope}`); + const decide = useMutation({ + mutationFn: (accept: boolean) => + api.agentGrants.consent({ request, accept, scopes: accept ? chosen : [], mode }), + onSuccess: ({ redirect }) => window.location.assign(redirect), + }); + const busy = decide.isPending || decide.isSuccess; + return ( +
+ {decide.isError && } + } title={c("oauthConsent.who.title")}> +
+ +
+ {info.client.name} + + {info.client.host + ? c("oauthConsent.who.fromHost", { host: info.client.host }) + : c("oauthConsent.who.registered")} + + + {info.redirect.loopback + ? c("oauthConsent.who.thisComputer") + : c("oauthConsent.who.sendsBack", { host: info.redirect.host })} + +
+
+ {info.loopbackOnly && ( + } + description={c("oauthConsent.who.loopbackWarning")} + /> + )} + {info.existing && } +
+
+ } + title={c("oauthConsent.scopes.title")} + description={c("oauthConsent.scopes.description")} + > +
+ setChosen(v as AgentScope[])} + > + {info.scopes.map((s) => ( + + ))} + + {info.offlineAccess && ( + + {c("oauthConsent.scopes.offline")} + + )} + {info.sites && ( + + {c("oauthConsent.scopes.sites", { count: info.sites.length })} + + )} + {chosen.length === 0 && ( + + {c("oauthConsent.scopes.none")} + + )} +
+
+ } + title={c("oauthConsent.mode.title")} + description={c("oauthConsent.mode.description")} + footer={ +
+ + +
+ } + > +
+ { + setMode(next); + setAutoConfirmed(false); + setSubmitted(false); + }} + legend={c("oauthConsent.mode.legend")} + only={info.modes} + /> + {needsConsent && ( + + )} +
+
+
+ ); +} diff --git a/apps/studio/test/login-setup.test.ts b/apps/studio/test/login-setup.test.ts index c23e4bc8..d4dfe55e 100644 --- a/apps/studio/test/login-setup.test.ts +++ b/apps/studio/test/login-setup.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { takeSetupToken } from "../src/routes/LoginPage.tsx"; +import { oauthRequest, takeSetupToken } from "../src/routes/LoginPage.tsx"; describe("owner setup link", () => { afterEach(() => { @@ -32,3 +32,14 @@ describe("owner setup link", () => { expect(takeSetupToken()).toBeUndefined(); }); }); + +describe("oauthRequest (RFC-0044 §4)", () => { + it("is the provider's signed request when the login page was reached from /oauth2/authorize", () => { + window.history.replaceState(null, "", "/login?client_id=c1&scope=site%3Aread&sig=abc%3D"); + expect(oauthRequest()).toBe("client_id=c1&scope=site%3Aread&sig=abc%3D"); + window.history.replaceState(null, "", "/login?client_id=c1"); + expect(oauthRequest()).toBeUndefined(); + window.history.replaceState(null, "", "/login"); + expect(oauthRequest()).toBeUndefined(); + }); +}); diff --git a/apps/studio/test/oauth-consent.test.tsx b/apps/studio/test/oauth-consent.test.tsx new file mode 100644 index 00000000..bf3c153a --- /dev/null +++ b/apps/studio/test/oauth-consent.test.tsx @@ -0,0 +1,135 @@ +/** + * RFC-0044 §4: the consent screen. The requested scopes as the member's role turns them (one it + * does not give is shown disabled and left out), the mode picker capped by role, and Approve / + * Deny posting the signed request as it came. + */ +import { TooltipProvider } from "@cloudhal/ui"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConsentInfo } from "../src/lib/api.ts"; +import { OAuthConsentPage } from "../src/routes/OAuthConsentPage.tsx"; +import { asRole, bodyOf, on, serve, wrap } from "./roles-fixtures.tsx"; + +const SIGNED = "client_id=c1&scope=site%3Aread+content%3Awrite+site%3Apublish&sig=abc"; +const INFO: ConsentInfo = { + client: { id: "c1", name: "Claude Code", host: "claude.ai" }, + redirect: { host: "127.0.0.1:33418", loopback: true }, + loopbackOnly: true, + scopes: [ + { requested: "site:read", granted: "site:read" }, + { requested: "content:write", granted: "content:draft:own" }, + { requested: "site:publish", granted: null }, + ], + offlineAccess: true, + modes: ["plan", "manual", "edits"], + defaultMode: "edits", +}; + +describe("OAuth consent screen", () => { + let assign: ReturnType void>>; + beforeEach(() => { + window.history.replaceState(null, "", `/oauth/consent?${SIGNED}`); + assign = vi.fn<(url: string | URL) => void>(); + vi.spyOn(window.location, "assign").mockImplementation(assign); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const renderPage = () => + render( + wrap( + + + , + ), + ); + + it("shows the client, narrows by role and approves with the kept scopes", async () => { + const fetch = serve( + ...asRole("contributor"), + on("GET", "/api/agent-grants/consent", { status: 200, body: INFO }), + on("POST", "/api/agent-grants/consent", { + status: 200, + body: { redirect: "http://127.0.0.1:33418/callback?code=x" }, + }), + ); + renderPage(); + expect(await screen.findByText("Connect Claude Code")).toBeTruthy(); + expect(screen.getByText("Identified by claude.ai")).toBeTruthy(); + expect(screen.getByText(/Every address this agent can send you back to/)).toBeTruthy(); + // The signed request goes to the server untouched. + const asked = fetch.mock.calls.find(([u]) => String(u).includes("/api/agent-grants/consent")); + expect(String(asked?.[0])).toContain(`request=${encodeURIComponent(SIGNED)}`); + const publish = screen.getByRole("checkbox", { name: /not in your role/ }); + expect(publish.getAttribute("aria-disabled") ?? publish.hasAttribute("disabled")).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: /content:draft:own/ })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => + expect(assign).toHaveBeenCalledWith("http://127.0.0.1:33418/callback?code=x"), + ); + expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toEqual({ + request: SIGNED, + accept: true, + scopes: ["site:read", "content:write"], + mode: "edits", + }); + }); + + it("asks for explicit consent before an Auto connection", async () => { + const fetch = serve( + ...asRole("owner"), + on("GET", "/api/agent-grants/consent", { + status: 200, + body: { ...INFO, modes: ["plan", "manual", "edits", "auto"] }, + }), + on("POST", "/api/agent-grants/consent", { + status: 200, + body: { redirect: "http://127.0.0.1:33418/callback?code=x" }, + }), + ); + renderPage(); + fireEvent.click(await screen.findByRole("radio", { name: /Auto/ })); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(await screen.findByTestId("auto-confirm")).toBeTruthy(); + expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toBeUndefined(); + fireEvent.click( + within(screen.getByTestId("auto-confirm")).getByText(/can publish without my approval/), + ); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => expect(assign).toHaveBeenCalled()); + expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toMatchObject({ mode: "auto" }); + }); + + it("denies without scopes", async () => { + const fetch = serve( + ...asRole("editor"), + on("GET", "/api/agent-grants/consent", { status: 200, body: INFO }), + on("POST", "/api/agent-grants/consent", { + status: 200, + body: { redirect: "http://127.0.0.1:33418/callback?error=access_denied" }, + }), + ); + renderPage(); + fireEvent.click(await screen.findByRole("button", { name: "Deny" })); + await waitFor(() => expect(assign).toHaveBeenCalled()); + expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toMatchObject({ + accept: false, + scopes: [], + }); + }); + + it("explains an expired or changed request", async () => { + serve( + ...asRole("editor"), + on("GET", "/api/agent-grants/consent", { + status: 400, + body: { error: "invalid_request", message: "this authorization request is invalid" }, + }), + ); + renderPage(); + expect(await screen.findByText("This request can't be used")).toBeTruthy(); + }); +}); diff --git a/docs/adr/0004-mcp-server.md b/docs/adr/0004-mcp-server.md index 54bf8172..30b7fd5c 100644 --- a/docs/adr/0004-mcp-server.md +++ b/docs/adr/0004-mcp-server.md @@ -23,6 +23,6 @@ MCP spec 2026-07-28 removed sessions and `initialize`; every request is self-des ## Addendum (2026-10-22): what was built instead Three parts of the Decision were not built. The stateless server, `createMcpHandler`, `outputSchema`/`structuredContent`, honest annotations, the deterministic `tools/list` and the layered approval (elicitation with a `confirmToken` fallback) are as decided. -- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1. *Superseded (2026-11-08):* [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md) brings OAuth 2.1 in three phases. Phase 1 (discovery, `packages/api/src/mcp-oauth.ts`) ships the RFC 9728 metadata, the `WWW-Authenticate` challenge on the 401 and the `insufficient_scope` step-up behind `McpMountOptions.oauth`; the authorization server (Better Auth's `mcp()` plugin, not `@cloudflare/workers-oauth-provider`) and client registration follow with their own ADR. Agent keys keep working. +- **No OAuth provider.** `@cloudflare/workers-oauth-provider` is not used. `/mcp` is mounted in the Hono API, which resolves the caller before the MCP handler sees the request: a session cookie for a person, or an agent key (Better Auth API key) as `x-api-key` or `Authorization: Bearer`. See [ADR-0011](0011-agent-layer.md) §5–6, which deferred OAuth 2.1. *Superseded (2026-11-08):* [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md) brings OAuth 2.1 in three phases. Phase 1 (discovery, `packages/api/src/mcp-oauth.ts`) ships the RFC 9728 metadata, the `WWW-Authenticate` challenge on the 401 and the `insufficient_scope` step-up behind `McpMountOptions.oauth`; the authorization server (Better Auth's `mcp()` plugin, not `@cloudflare/workers-oauth-provider`) shipped in Phase 2 ([ADR-0045](0045-mcp-authorization-server.md)); client registration follows in Phase 3. Agent keys keep working. - **No server-minted handles.** Tools take the contract ids (`snap_…` and the other prefixed ids) and the node's `rev`; every call is re-authorized by the policy engine, and approval state is an HMAC-signed `requestState` bound to the tool, the arguments and the principal (ADR-0011 §4), not a stored handle. - **More than four scopes.** The scope list grew with the features (content, preview, assets, analytics, comments, schedules and releases, `:own` variants, person-only scopes). The single source is `KEY_SCOPES` and `PERSON_SCOPES` in `packages/agent-runtime/src/policy.ts`; roles are in `src/roles.ts` ([ADR-0018](0018-roles-and-invites.md)). Each tool's scopes are in the [tool reference](../../packages/mcp-server/README.md#tool-reference). diff --git a/docs/adr/0010-auth-better-auth.md b/docs/adr/0010-auth-better-auth.md index b49e4139..455028f9 100644 --- a/docs/adr/0010-auth-better-auth.md +++ b/docs/adr/0010-auth-better-auth.md @@ -10,7 +10,7 @@ Cloudhal needs user authentication for Studio, a tenant/role model, non-interact ## Decision Adopt **Better Auth 1.7.x** (MIT; Vercel-owned since 2026-07 with an open-source, framework-agnostic commitment): - Studio identity: core email+password (+ magic link / GitHub / Google when configured), `admin` plugin for roles; `organization` plugin as the tenant and membership model; `@better-auth/api-key` with organization-owned keys for non-interactive agents. -- MCP authorization (Sprint 5): `@better-auth/mcp` + `@better-auth/cimd` + `jwt()` — RFC 9728 resource metadata, PKCE, RFC 8707 `resource` → `aud`, JWKS verification in the MCP server; DCR disabled; DPoP optional. +- MCP authorization (Sprint 5): `@better-auth/mcp` + `@better-auth/cimd` + `jwt()` — RFC 9728 resource metadata, PKCE, RFC 8707 `resource` → `aud`, JWKS verification in the MCP server; DCR disabled; DPoP optional. *Amended (2026-11-09, [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md), [ADR-0045](0045-mcp-authorization-server.md)):* CIMD first, **DCR as a limited fallback** (on by default with rate limits, caps and pruning, `CLOUDHAL_MCP_DCR=off`; Phase 3), **no DPoP in v1** (later, once a client we target sends it). Phase 2 ships the authorization server itself: `mcp()` + `jwt()` in `createInstance`, Ed25519 JWT access tokens bound to `/mcp`, refresh rotation, consent on a Studio screen, `agent_grants` (migration `0031_oauth`). - Database: the Kysely adapter over the same better-sqlite3/D1 handle as the storage layer; Better Auth's DDL is **checked into `packages/storage/migrations`** so one migration pipeline governs everything. - Cloudhal keeps one actor model: `Actor { kind: "human", id: user_ }`; the policy engine decides, Better Auth only authenticates and issues tokens. diff --git a/docs/adr/0011-agent-layer.md b/docs/adr/0011-agent-layer.md index fe07754e..ae00531d 100644 --- a/docs/adr/0011-agent-layer.md +++ b/docs/adr/0011-agent-layer.md @@ -21,7 +21,7 @@ Sprint 5 makes Cloudhal usable *by agents*: an MCP client must be able to create |---|---|---|---| | Register tools directly with the SDK (zod schemas) | Less code | Policy/audit/tests tied to SDK types; zod in our tree despite ADR-0001 | Contract schemas already exist; `fromJsonSchema` reuses them | | Approval only via `confirmToken` | Works everywhere | The agent sees the token; relies on the host to involve a human | Elicitation is the first-class path; token is the documented fallback (ADR-0004) | -| OAuth 2.1 (`@better-auth/mcp` + CIMD) now | Spec-aligned remote auth | Much larger surface; not needed for local/CI agents | Sprint 6; API keys cover agents today | +| OAuth 2.1 (`@better-auth/mcp` + CIMD) now | Spec-aligned remote auth | Much larger surface; not needed for local/CI agents | Sprint 6; API keys cover agents today. **Done (2026-11-09):** RFC-0044 Phase 1 (discovery) and Phase 2 (authorization server, [ADR-0045](0045-mcp-authorization-server.md)); a token resolves to the same `Principal` a key does, so §5–6 still hold for keys | | Durable Object / sessionful server | Server-initiated elicitation works for legacy clients | Deprecated pattern; session affinity | Stateless per ADR-0004 | ## Consequences diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md new file mode 100644 index 00000000..caa212d0 --- /dev/null +++ b/docs/adr/0045-mcp-authorization-server.md @@ -0,0 +1,29 @@ +# ADR-0045: The MCP authorization server (RFC-0044 Phase 2) +- Status: Accepted +- Date: 2026-11-09 +- Related: [RFC-0044](../rfcs/0044-mcp-oauth-authorization.md) (Decisions 2026-11-08), ADR-0004 (addendum "No OAuth provider", superseded), ADR-0010 (Better Auth; MCP authorization amended), ADR-0011 §5–6, ADR-0015 / ADR-0016 (modes, elevations), RFC-0005 (I1, I5, I7), RFC-0034 A2 (site roles), RFC-0036 (playground) +- Sources: `@better-auth/mcp` 1.7.18 and `@better-auth/oauth-provider` 1.7.18 as installed (type definitions and `dist/` read 2026-11-09: `mcp()`, `OAuthOptions`, `consentEndpoint`, `authorizeEndpoint`, `createJwtAccessToken`, `seedResourcesOnce`), `better-auth` 1.7.18 `jwt()` (`JwtOptions`), `jose` 6.2.12, [MCP Authorization 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html), [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html), [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html), [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) + +## Context +RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization server nobody ran. Phase 2 runs it, on Cloudflare (D1) and self-hosted Node (SQLite), without a second account store and without a second authorization path next to agent keys. + +## Decision +1. **Better Auth's `mcp()` plugin plus `jwt()`** in `createInstance` (`packages/api/src/auth/oauth.ts`), only when the runtime passes `AuthOptions.mcpOauth`. The issuer is Better Auth's base URL on the public origin, `/api/auth`; the resource is `/mcp`. Grants: authorization code with PKCE (S256 only) and refresh token; scopes are key scopes plus `offline_access`, no `openid`. The AS metadata is served by `createApi` at `/.well-known/oauth-authorization-server/api/auth` and at the root alias (Decision 6). +2. **Tokens:** RFC 9068 JWT access tokens (`typ: at+jwt`, EdDSA/Ed25519, 1 h), `aud` = the resource, `iss` = the issuer; refresh tokens for 30 d, rotated on every use with `mcp()`'s 30 s reuse window (Decision 2); authorization codes 5 min. `/mcp` verifies signature, `iss`, `aud`, `exp` and `typ` with `jose` against the `jwks` table read through the instance (cached per isolate by `kid`, never fetched over HTTP), pinned to EdDSA. +3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row or no membership is a 401 with the `invalid_token` challenge. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged. +4. **Consent is Cloudhal's.** The provider's `consentPage` is Studio's `/oauth/consent`; the screen posts to `/api/agent-grants/consent`, which verifies the signed request, narrows the requested scopes by the member's **installation** role (the cap #173's step-up relies on), caps the mode, calls the provider's consent endpoint with the scopes kept (a subset of the request, as the provider requires), then writes the grant, `agent_owner` and the `connect_agent` audit row in one batch. The provider's own `/oauth2/consent` refuses HTTP callers (a per-instance server key) and is fenced in `createApi` with every other provider endpoint that is not authorize, token or revoke (client and consent CRUD, introspection, userinfo, registration, the `jwt()` plugin's `/token`). +5. **No authorization server without a stable origin** (`mcpOauthFor`): the admin Worker uses the request's origin (as Better Auth's `baseURL` already does); `cloudhal studio` needs `--base-url` / `CLOUDHAL_BASE_URL` and logs why OAuth is off otherwise; plain HTTP is accepted on loopback only; `CLOUDHAL_MCP_OAUTH=off` turns it off on both. The playground stays out (Decision 10). +6. **Registration is Phase 3.** Phase 2 has no CIMD and no DCR; `OauthServer.createClient` (server-side only) creates a pre-registered public client for tests, the provider's `clientPrivileges` admitting only the server's own calls. + +## Alternatives considered +| Alternative | Pros | Cons | Why not | +|---|---|---|---| +| Let Studio call the provider's `/oauth2/consent` directly, with a hook writing the grant | One request | The provider requires consent scopes ⊆ requested, so `:own` narrowing could not be expressed; a hook could not refuse cleanly | Cloudhal's endpoint narrows, the grant caps the token | +| `requireMcpAuth` / `createMcpProtectedRequestHandler` from `@better-auth/mcp` | Ready-made verifier | Fetches JWKS over HTTP from the deployment itself; its own challenge format; no grant read | Local `jose` verification against the instance's keys (RFC-0044 §2) | +| Opaque tokens + introspection | Instant revocation | A Better Auth read per call anyway, no mode or elevation | The grant read gives both | +| Issuer per request without a configured origin on Node | Works on any address | Tokens and `iss` change with the address a client used | An issuer must be stable (§10) | + +## Consequences +- Positive: Claude, ChatGPT, Cursor and other remote MCP clients can connect by URL once Phase 3 adds registration; a token is never more than a key of the same member; revoking a connection or removing a member takes effect on the next call. +- Negative: one extra indexed read per Better Auth instance (the plugin seeds the `oauthResource` row at init; per request on Workers); a `BETTER_AUTH_SECRET` rotation invalidates the signing key and every token. +- Risks: `@better-auth/mcp` is young (1.7.x); the family is pinned exactly and `better-auth-pins.test.ts` keeps it in step. Consent memory (Decision 8) is the provider's: a subset of granted scopes skips the screen and writes no audit row in Phase 2. diff --git a/docs/adr/README.md b/docs/adr/README.md index 928ea8fc..17026bda 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,3 +61,4 @@ One file per decision, numbered, immutable once accepted (write a new ADR to sup | [0042](0042-first-class-collections.md) | First-class collections: `Site.collections` is a registry (declared keys, collections with entries, `posts`) with no data migration; per-locale labels, icon, feed, sitemap and sort; immutable keys; a new collection's prefix checked for overlaps; agents create collections only through approved `create_collection` (RFC-0040) | | [0043](0043-typed-custom-fields.md) | Typed custom fields: nine closed types on `Site.collections..fields` (discriminator `type`), values in `ContentEntry.fields`, one eval-free checker (`@cloudhal/core-ts/fields`) for store, API and MCP that also emits the agents' JSON Schema, `requiredToPublish` at review and publish, per-field localization, definitions on the site node with caps, renames/type changes/removals only as snapshotted, approved operations (RFC-0041) | | [0044](0044-collections-stage-3.md) | Collections stage 3: JSON-LD presets (Event, JobPosting, Product, Recipe) mapped from fields through a closed table, expiry as a render-time gate (`unlist`/`hide`, no automation), listings filtered and sorted by field with one shared evaluator and no indexes yet, listing pages as ordinary pages and per-collection feeds through a middleware (RFC-0042) | +| [0045](0045-mcp-authorization-server.md) | MCP authorization server: Better Auth `mcp()` + `jwt()` on the Studio database, issuer `/api/auth`, Ed25519 JWT access tokens (1 h, `aud` = `/mcp`) verified locally plus one `agent_grants` read per call, refresh rotation with a 30 s reuse window, consent on Studio's screen narrowed by the installation role, a token resolves to the same `Principal` as a key; no AS without a stable public origin (RFC-0044 Phase 2) | diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 1b3b490a..966dad90 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -489,6 +489,9 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio - **Page editor › Content list** (RFC-0042 §4): under collection / limit / sort, a lazy "Only entries where" block over the listed collection's listing fields — rows of field, condition (only that type's operators, worded "is before / after" for dates), value (now or a date for dates, the options for a choice, a `Checkbox.Group` for "is one of", yes / no) and a ghost square remove; secondary "Add condition" (up to four); "Order by field" (number and date fields) with its direction. A save refused with `invalid_query` shows each issue under its row. A collection without listing fields shows a muted line with a link to its settings. - **Approvals and Activity**: `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`, `list_collections`, `create_collection`, `update_collection` and `preview_structured_data` have labels in `tools.*`; `change_field_type` and `remove_field` are `CONFIRM_TOOLS` (the server's dry-run prompt shows, and approving asks first). Field and RFC-0042 error codes (`invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use`, `gates_unsupported`) read through `apiErrorText` (`errors.api.*`); a 409 whose code has its own words shows them, not "someone else changed this". An `auto`-mode publish held back by a new or earlier collection expiry gives the reason `approvals.autoCheck.expires`. +### 2ai. OAuth consent (`/oauth/consent`; RFC-0044 §4, ADR-0045) +Template `consent`: where a remote MCP client's authorization request lands after sign-in, inside the account frame. A `PageLayout` (`width="narrow"`, title "Connect ") of three `SectionCard`s, never nested: **Who is asking** (the client's name in an `InsetPanel`, the host it is identified by or "Registered with this Studio", the host it sends the browser back to; an `alert` `Banner` when every redirect is on this computer; an info `Banner` for a step-up), **What it may do** (a `Checkbox.Group` of the requested scopes in the agent-key wording; one the role narrows says so in its label, one the role does not give is disabled with "not in your role"; the refresh-token and site-limited lines as secondary text) and **How it works** (`ModePicker` with the modes the role allows; Deny and Approve in the card's footer, Approve disabled with nothing ticked). The page's query string is the provider's signed request, sent back untouched; Approve and Deny navigate to the URL the server answers. Words in the lazy namespace `oauthConsent`. + ## 3. Sidebar specification ```tsx diff --git a/packages/api/package.json b/packages/api/package.json index 89dd2b0f..13ccbdfe 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -37,9 +37,12 @@ "hono": "catalog:", "better-auth": "catalog:", "@better-auth/api-key": "catalog:", + "@better-auth/mcp": "catalog:", + "@better-auth/oauth-provider": "catalog:", "@better-auth/core": "catalog:", "@better-auth/utils": "catalog:", "better-call": "catalog:", + "jose": "catalog:", "@cloudhal/agent-runtime": "workspace:*", "@cloudhal/mcp-server": "workspace:*", "@cloudhal/astro-renderer": "workspace:*", diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 41b6dd09..98e79cc6 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -13,6 +13,7 @@ import { type BuildHub, createBuildHub } from "./build.ts"; import type { ApiDeps, Env } from "./context.ts"; import { errorHandler } from "./errors.ts"; import { mountMcp } from "./mcp-mount.ts"; +import { fencedOauthPath, mountOauthServer } from "./oauth-server.ts"; import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts"; import { activity, deploymentActivity } from "./routes/activity.ts"; import { mountAdmin } from "./routes/admin-mount.ts"; @@ -152,33 +153,17 @@ export function createApi(deps: ApiDeps) { }); } - // CSRF: state-changing requests that may carry a session cookie must come from this origin (or a - // trusted one). Better Auth checks its own routes; /api/admin uses a bearer token, not cookies. // RFC-0020 §6: page changes for Studio's Build view (MCP tools and Studio saves report here). const build = deps.build ?? createBuildHub(deps.now); const trusted = new Set(deps.trustedOrigins ?? []); - app.use("/api/*", async (c, next) => { - const method = c.req.method; - const path = new URL(c.req.url).pathname; - if (method === "GET" || method === "HEAD" || method === "OPTIONS") return next(); - if (path.startsWith("/api/auth/") || path.startsWith("/api/admin/")) return next(); - const origin = c.req.header("origin"); - const own = new URL(c.req.url).origin; - const fetchSite = c.req.header("sec-fetch-site"); - const ok = - origin !== undefined - ? origin === own || trusted.has(origin) - : fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none"; - if (!ok) return c.json({ error: "forbidden", message: "cross-origin request refused" }, 403); - await next(); - }); + app.use("/api/*", csrfGuard(trusted)); // CSRF for cookie-carrying writes const shared: SharedContext = { build, ownership, policy, defer, clock, uploadSecret, schedules }; app.use("*", async (c, next) => { setContext(c, deps, shared); // The fenced Better Auth endpoints never reach credential resolution. // Raw and decoded: whichever form Better Auth or Hono would dispatch on is fenced. - if (FENCED_AUTH_PATHS.test(new URL(c.req.url).pathname) || FENCED_AUTH_PATHS.test(c.req.path)) + if (fencedAuthPath(new URL(c.req.url).pathname) || fencedAuthPath(c.req.path)) return c.json({ error: "not_found", message: "not found" }, 404); const principal = await resolvePrincipal(c, deps); c.set("principal", principal); @@ -211,6 +196,8 @@ export function createApi(deps: ApiDeps) { if (deps.mcp) mountMcp(app, { ...deps, mcp: deps.mcp }, { build, ownership, schedules }); if (deps.agentKeys) app.route("/api/agent-keys", agentKeys(deps.agentKeys)); + // RFC-0044 Phase 2: the authorization server's metadata, consent and connections. + if (deps.oauthServer) mountOauthServer(app, deps.oauthServer); app.route( "/api", @@ -220,6 +207,7 @@ export function createApi(deps: ApiDeps) { features: { agentKeys: Boolean(deps.agentKeys), mcp: Boolean(deps.mcp), + mcpOauth: Boolean(deps.oauthServer && deps.mcp?.oauth), activity: Boolean(deps.audit ?? deps.mcp?.audit), previewLinks: Boolean(deps.preview), deployTransfer: Boolean(deps.admin) && problems.length === 0, @@ -280,6 +268,33 @@ export function createApi(deps: ApiDeps) { return app; } +/** Better Auth paths HTTP callers never reach (raw and decoded forms are both checked). */ +function fencedAuthPath(path: string): boolean { + return FENCED_AUTH_PATHS.test(path) || fencedOauthPath(path); +} + +/** + * CSRF: state-changing requests that may carry a session cookie must come from this origin (or a + * trusted one). Better Auth checks its own routes; /api/admin uses a bearer token, not cookies. + */ +function csrfGuard(trusted: ReadonlySet) { + return async (c: Context, next: () => Promise) => { + const method = c.req.method; + const path = new URL(c.req.url).pathname; + if (method === "GET" || method === "HEAD" || method === "OPTIONS") return next(); + if (path.startsWith("/api/auth/") || path.startsWith("/api/admin/")) return next(); + const origin = c.req.header("origin"); + const own = new URL(c.req.url).origin; + const fetchSite = c.req.header("sec-fetch-site"); + const ok = + origin !== undefined + ? origin === own || trusted.has(origin) + : fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none"; + if (!ok) return c.json({ error: "forbidden", message: "cross-origin request refused" }, 403); + await next(); + }; +} + /** What `createApi` builds once and every request reads next to its `ApiDeps`. */ interface SharedContext { readonly build: BuildHub; diff --git a/packages/api/src/auth-node.ts b/packages/api/src/auth-node.ts index d03d9c3c..3911b403 100644 --- a/packages/api/src/auth-node.ts +++ b/packages/api/src/auth-node.ts @@ -4,7 +4,12 @@ */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type CloudhalAuth, createAuth, trustedOriginsWithOwn } from "./auth.ts"; +import { + type CloudhalAuth, + createAuth, + type McpOauthServerOptions, + trustedOriginsWithOwn, +} from "./auth.ts"; import { CLIENT_ADDRESS_HEADER } from "./tokens.ts"; /** @@ -28,6 +33,8 @@ export async function createAuthFor(options: { setupToken?: string | undefined; /** `"always"` runs Better Auth's Origin check under `NODE_ENV=test` (`AuthOptions.originCheck`). */ originCheck?: "always" | undefined; + /** RFC-0044: run the OAuth authorization server (`mcpOauthFor` decides). */ + mcpOauth?: McpOauthServerOptions | undefined; }): Promise { const mod = await import("better-sqlite3"); const { BetterSqlite3Driver } = await import("@cloudhal/storage/sqlite"); @@ -48,6 +55,7 @@ export async function createAuthFor(options: { // `serveNode` writes the client's address here; Better Auth's own default is X-Forwarded-For. clientAddressHeader: CLIENT_ADDRESS_HEADER, originCheck: options.originCheck, + mcpOauth: options.mcpOauth, }); } diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 76f8a6bd..b481497c 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -1,12 +1,28 @@ import type { Auth } from "better-auth"; +import { createAgentGrantService } from "./auth/agent-grants.ts"; import { createAgentKeyService } from "./auth/agent-keys.ts"; import { authDeps } from "./auth/deps.ts"; +import { createOauthClient } from "./auth/oauth-clients.ts"; import { createPrincipalResolver } from "./auth/principal.ts"; import { createStaffService } from "./auth/staff.ts"; import { type AuthOptions, type CloudhalAuth, signUpMode } from "./auth/types.ts"; import type { ActorResolver } from "./context.ts"; +export { + type AgentGrantService, + type AgentGrantSummary, + type ConsentInfo, + type ConsentScope, + consentScopes, +} from "./auth/agent-grants.ts"; +export { agentIdForGrant } from "./auth/grants.ts"; export { actorIdForUser, agentIdForKey, agentKeyStateFromSql } from "./auth/key-rows.ts"; +export { + type McpOauthServerOptions, + mcpOauthFor, + OAUTH_SCOPES, + oauthEndpoints, +} from "./auth/oauth.ts"; export { ADDABLE_KEY_SCOPES, AGENT_KEY_RATE_LIMIT, @@ -19,6 +35,7 @@ export { type CloudhalAuth, type KeyNarrowing, NOT_A_MEMBER_MESSAGE, + type OauthServer, SETUP_TOKEN_HEADER, type StaffService, signUpMode, @@ -42,5 +59,22 @@ export function createAuth(options: AuthOptions): CloudhalAuth { staff: createStaffService(d), signUp: signUpMode(options), instance: d.instance as unknown as Auth, + ...(d.endpoints && options.mcpOauth + ? { + oauth: { + issuer: d.endpoints.issuer, + resource: options.mcpOauth.resource, + grants: createAgentGrantService(d), + // RFC 8414: the server-only `getOAuthServerConfig` endpoint; `createApi` serves it. + metadata: async () => + (await ( + d.instance.api as unknown as { + getOAuthServerConfig(): Promise>; + } + ).getOAuthServerConfig()) ?? {}, + createClient: (input) => createOauthClient(d, input), + }, + } + : {}), }; } diff --git a/packages/api/src/auth/access-token.ts b/packages/api/src/auth/access-token.ts new file mode 100644 index 00000000..65f03766 --- /dev/null +++ b/packages/api/src/auth/access-token.ts @@ -0,0 +1,123 @@ +/** + * RFC-0044 §2, §5: verifying an OAuth access token on `/mcp`. The token is an RFC 9068 JWT + * (`typ: at+jwt`) signed with the `jwt()` plugin's key; `/mcp` checks the signature, `iss` + * (the issuer), `aud` (the `/mcp` resource, RFC 8707), `exp` and the claims it uses (`sub`, + * `client_id`/`azp`, `scope`). The public keys come from the `jwks` table through the Better + * Auth instance (never by fetching the deployment's own `/api/auth/jwks` over HTTP) and are + * cached per isolate. + * + * Worker-safe: `jose` uses WebCrypto. + */ +import { createLocalJWKSet, decodeProtectedHeader, type JSONWebKeySet, jwtVerify } from "jose"; +import { ACCESS_TOKEN_ALG } from "./oauth.ts"; + +/** A bearer value shaped like an access token: three base64url segments, header `typ: at+jwt`. */ +export function looksLikeAccessToken(value: string): boolean { + // Length first, then a split: no pattern runs over the external input (AGENTS.md rule 13). + if (value.length > 8192) return false; + const parts = value.split("."); + if (parts.length !== 3 || parts.some((p) => p.length === 0)) return false; + try { + const header = decodeProtectedHeader(value); + return typeof header.typ === "string" && header.typ.toLowerCase() === "at+jwt"; + } catch { + return false; + } +} + +/** The bearer value of `headers` when it is an access token (`looksLikeAccessToken`). */ +export function accessTokenOf(headers: Headers): string | undefined { + const bearer = /^Bearer\s+(\S+)\s*$/i.exec(headers.get("authorization") ?? "")?.[1]; + return bearer && looksLikeAccessToken(bearer) ? bearer : undefined; +} + +export interface VerifiedAccessToken { + /** The member (Better Auth user id). */ + readonly sub: string; + /** The OAuth client (`client_id`, else `azp`). */ + readonly clientId: string; + readonly scopes: string[]; + readonly jti: string | undefined; + readonly exp: number; +} + +export interface AccessTokenVerifierOptions { + readonly issuer: string; + /** The resource identifier every token's `aud` must contain. */ + readonly audience: string; + /** The public keys (the `jwt()` plugin's `getJwks`, called server-side). */ + readonly jwks: () => Promise; + readonly now?: (() => Date) | undefined; +} + +/** How long a key set is reused before it is read again (a rotation shows up within it). */ +const JWKS_TTL_MS = 5 * 60_000; +/** A token naming an unknown `kid` re-reads the keys at most this often (forged kids are free). */ +const JWKS_MISS_INTERVAL_MS = 10_000; + +interface CachedKeys { + keys: JSONWebKeySet; + set: ReturnType; + readAt: number; +} + +/** Per isolate, per issuer (one Worker isolate may serve several hosts). */ +const cache = new Map(); + +/** Forgets the cached keys (tests, and after a key rotation in the same process). */ +export function clearAccessTokenKeyCache(): void { + cache.clear(); +} + +export function createAccessTokenVerifier(options: AccessTokenVerifierOptions) { + const now = () => (options.now?.() ?? new Date()).getTime(); + const load = async (): Promise => { + const keys = await options.jwks(); + const entry = { keys, set: createLocalJWKSet(keys), readAt: now() }; + cache.set(options.issuer, entry); + return entry; + }; + const keysFor = async (kid: string | undefined): Promise => { + const cached = cache.get(options.issuer); + if (!cached || now() - cached.readAt > JWKS_TTL_MS) return load(); + const known = kid === undefined || cached.keys.keys.some((k) => k.kid === kid); + if (!known && now() - cached.readAt > JWKS_MISS_INTERVAL_MS) return load(); + return cached; + }; + + /** The verified claims, or `undefined` for any token that is not one of ours and current. */ + return async (token: string): Promise => { + try { + const { kid } = decodeProtectedHeader(token); + const { set } = await keysFor(kid); + const { payload } = await jwtVerify(token, set, { + issuer: options.issuer, + audience: options.audience, + typ: "at+jwt", + // The one algorithm the server signs with (`oauthPlugins`): no algorithm confusion. + algorithms: [ACCESS_TOKEN_ALG], + requiredClaims: ["sub", "exp", "iat"], + currentDate: new Date(now()), + }); + const clientId = + typeof payload.client_id === "string" + ? payload.client_id + : typeof payload.azp === "string" + ? payload.azp + : undefined; + if (!payload.sub || !clientId || typeof payload.exp !== "number") return undefined; + const scope = typeof payload.scope === "string" ? payload.scope : ""; + return { + sub: payload.sub, + clientId, + scopes: scope.split(" ").filter((s) => s.length > 0), + jti: payload.jti, + exp: payload.exp, + }; + } catch { + return undefined; + } + }; +} + +export type AccessTokenVerifier = ReturnType; diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts new file mode 100644 index 00000000..9a89c56b --- /dev/null +++ b/packages/api/src/auth/agent-consent.ts @@ -0,0 +1,252 @@ +/** + * RFC-0044 §4: consent is Cloudhal's. Studio's screen posts here; the requested scopes are + * narrowed by the member's **installation** role (`narrow(requested, roleScopes(role))`, so a site + * role can only narrow further, which #173's step-up cap relies on), the mode is capped like a + * key's, the provider's consent endpoint is called with the scopes the member kept, and the + * `agent_grants` row, `agent_owner` and the audit row are written in one batch. + */ + +import { verifyOAuthQueryParams } from "@better-auth/oauth-provider"; +import { + type AgentMode, + type AuditEntry, + AuditLog, + clampMode, + isKeyScope, + modeCap, + modesFor, + narrow, + REMOTE_AGENT_MODES, + type Role, + roleScopes, + type Scope, +} from "@cloudhal/agent-runtime"; +import type { AuthDeps } from "./deps.ts"; +import { type AgentGrant, agentIdForGrant } from "./grants.ts"; +import { SERVER_KEY_HEADER } from "./instance.ts"; +import { OFFLINE_ACCESS } from "./oauth.ts"; +import { AuthError } from "./types.ts"; + +/** One requested scope and what the member's role turns it into (`null`: not in the role). */ +export interface ConsentScope { + readonly requested: Scope; + readonly granted: Scope | null; +} + +export interface ConsentInfo { + readonly client: { + readonly id: string; + readonly name: string; + /** The `client_id` host of a metadata-document client (`claude.ai`), else absent. */ + readonly host?: string | undefined; + }; + /** Where the browser is sent back: its host, and whether it is this computer (loopback). */ + readonly redirect: { readonly host: string; readonly loopback: boolean }; + /** Every registered redirect URI is loopback (the spec's extra warning). */ + readonly loopbackOnly: boolean; + readonly scopes: readonly ConsentScope[]; + /** The client asked for a refresh token (`offline_access`). */ + readonly offlineAccess: boolean; + readonly modes: readonly AgentMode[]; + readonly defaultMode: AgentMode; + /** The member's live grant for this client: a step-up shows its scopes pre-checked. */ + readonly existing?: { readonly scopes: readonly Scope[]; readonly mode: AgentMode } | undefined; + /** RFC-0034 A2: the sites a site-limited member's agent will reach. */ + readonly sites?: readonly string[] | undefined; +} + +interface ClientRow { + clientId: string; + name?: string | null; + redirectUris?: string[] | string | null; + disabled?: boolean | null; +} + +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); + +function hostOf(uri: string): { host: string; loopback: boolean } | undefined { + try { + const url = new URL(uri); + return { host: url.host, loopback: LOOPBACK.has(url.hostname) }; + } catch { + return undefined; + } +} + +function listOf(value: string[] | string | null | undefined): string[] { + if (Array.isArray(value)) return value; + if (typeof value !== "string") return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; + } catch { + return value.split(",").filter(Boolean); + } +} + +/** The requested key scopes, each with what the role gives (inv. 1; person-only never appears). */ +export function consentScopes(requested: readonly string[], role: Role): ConsentScope[] { + const held = roleScopes(role); + return requested + .filter((s): s is Scope => isKeyScope(s)) + .map((s) => ({ requested: s, granted: narrow([s], held)[0] ?? null })); +} + +/** The signed authorization request the provider sent the browser to the consent page with. */ +async function signedRequest(d: AuthDeps, oauthQuery: string) { + if (oauthQuery.length > 8192 || !(await verifyOAuthQueryParams(oauthQuery, d.secret))) + throw new AuthError("this authorization request is invalid or expired; start again", { + code: "invalid_request", + }); + const query = new URLSearchParams(oauthQuery); + const clientId = query.get("client_id") ?? ""; + const client = clientId + ? await (await d.context()).adapter.findOne({ + model: "oauthClient", + where: [{ field: "clientId", value: clientId }], + }) + : null; + if (!client || client.disabled) + throw new AuthError("unknown client", { status: 404, code: "not_found" }); + const requested = (query.get("scope") ?? "").split(" ").filter(Boolean); + return { query, client, requested }; +} + +/** What Studio's consent screen shows (§4). */ +export async function consentInfo( + d: AuthDeps, + headers: Headers, + oauthQuery: string, +): Promise { + const who = await d.caller(headers, "sign in to connect an agent"); + const { query, client, requested } = await signedRequest(d, oauthQuery); + const redirect = hostOf(query.get("redirect_uri") ?? "") ?? { host: "", loopback: false }; + const registered = listOf(client.redirectUris); + const existing = await d.grants.live(who.userId, client.clientId); + const cap = modeCap(who.member.role); + const documentHost = client.clientId.startsWith("https://") + ? hostOf(client.clientId)?.host + : undefined; + const sites = who.member.sites ? Object.keys(who.member.sites) : undefined; + return { + client: { + id: client.clientId, + name: client.name?.trim() || client.clientId, + ...(documentHost ? { host: documentHost } : {}), + }, + redirect, + loopbackOnly: registered.length > 0 && registered.every((u) => hostOf(u)?.loopback), + scopes: consentScopes(requested, who.member.role), + offlineAccess: requested.includes(OFFLINE_ACCESS), + modes: modesFor(who.member.role), + defaultMode: clampMode(existing?.mode ?? "edits", cap), + ...(existing ? { existing: { scopes: existing.scopes, mode: existing.mode } } : {}), + ...(sites ? { sites } : {}), + }; +} + +/** Forwards the decision to the provider; the URL the browser goes to next. */ +async function forward( + d: AuthDeps, + headers: Headers, + body: Record, +): Promise { + const origin = new URL(d.endpoints?.issuer ?? "http://invalid").origin; + const response = await d.instance.handler( + new Request(`${d.endpoints?.issuer}/oauth2/consent`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + origin, + cookie: headers.get("cookie") ?? "", + [SERVER_KEY_HEADER]: d.internal.serverKey, + }, + body: JSON.stringify(body), + }), + ); + const answer = (await response.json().catch(() => ({}))) as { url?: unknown; message?: unknown }; + if (!response.ok || typeof answer.url !== "string") + throw new AuthError( + typeof answer.message === "string" ? answer.message : "the authorization server refused", + { code: "consent_failed" }, + ); + return answer.url; +} + +export interface ConsentInput { + headers: Headers; + oauthQuery: string; + accept: boolean; + /** The requested scopes the member left ticked. */ + scopes: readonly string[]; + mode: AgentMode; + /** The audit row (`connect_agent`), written in the grant's batch. */ + audit: (grant: { agentId: string; scopes: readonly Scope[] }) => AuditEntry; +} + +/** + * The member's decision: scopes narrowed by the installation role, the mode capped, the + * provider's consent, then the grant, `agent_owner` and the audit row in one batch. + */ +export async function giveConsent( + d: AuthDeps, + input: ConsentInput, +): Promise<{ redirect: string; grant?: AgentGrant }> { + if (!d.endpoints) throw new AuthError("OAuth is off", { status: 404, code: "not_found" }); + const who = await d.caller(input.headers, "sign in to connect an agent"); + const { client, requested } = await signedRequest(d, input.oauthQuery); + if (!input.accept) + return { + redirect: await forward(d, input.headers, { accept: false, oauth_query: input.oauthQuery }), + }; + const role = who.member.role; + const kept = consentScopes(requested, role).filter( + (s) => s.granted !== null && input.scopes.includes(s.requested), + ); + const effective = narrow( + kept.map((s) => s.requested), + roleScopes(role), + ); + if (effective.length === 0) + throw new AuthError("choose at least one permission your role holds", { + code: "no_scope", + }); + if (!REMOTE_AGENT_MODES.includes(input.mode)) + throw new AuthError("unknown mode", { code: "bad_request" }); + d.capProblem(role, input.mode); + // The provider records the requested scopes the member kept (its consent must be a subset of + // the request); the token carries them, and the grant caps them on every call. + const providerScopes = [ + ...kept.map((s) => s.requested), + ...(requested.includes(OFFLINE_ACCESS) ? [OFFLINE_ACCESS] : []), + ]; + const redirect = await forward(d, input.headers, { + accept: true, + scope: providerScopes.join(" "), + oauth_query: input.oauthQuery, + }); + const now = d.now().toISOString(); + // One batch: the grant, the person behind the agent actor (RFC-0005 §4, as `recordAgent` does + // for a key) and the audit row. + const grant = await d.grants.consent( + { + userId: who.userId, + clientId: client.clientId, + clientName: (client.name?.trim() || client.clientId).slice(0, 100), + scopes: effective, + mode: input.mode, + }, + (grantId) => { + const agentId = agentIdForGrant(grantId); + return [ + { + sql: "INSERT INTO agent_owner (agent_id, user_id, key_id, created_at) VALUES (?, ?, NULL, ?) ON CONFLICT (agent_id) DO NOTHING", + params: [agentId, who.userId, now], + }, + AuditLog.statement(input.audit({ agentId, scopes: effective })), + ]; + }, + ); + return { redirect, grant }; +} diff --git a/packages/api/src/auth/agent-grants.ts b/packages/api/src/auth/agent-grants.ts new file mode 100644 index 00000000..f8670e52 --- /dev/null +++ b/packages/api/src/auth/agent-grants.ts @@ -0,0 +1,108 @@ +/** + * RFC-0044 §4: the connections of OAuth MCP clients as Studio lists them, and their revocation. + * Consent itself is `agent-consent.ts`. + */ +import { + type AgentMode, + type AuditEntry, + AuditLog, + clampMode, + modeCap, + narrow, + type Role, + roleScopes, + type Scope, +} from "@cloudhal/agent-runtime"; +import { consentInfo, giveConsent } from "./agent-consent.ts"; +import type { AuthDeps } from "./deps.ts"; +import { type AgentGrant, agentIdForGrant } from "./grants.ts"; +import { actorIdForUser, roleScopesHold } from "./key-rows.ts"; +import { AuthError } from "./types.ts"; + +export type { ConsentInfo, ConsentInput, ConsentScope } from "./agent-consent.ts"; +export { consentScopes } from "./agent-consent.ts"; + +export interface AgentGrantSummary { + readonly id: string; + readonly agentId: string; + readonly clientId: string; + readonly clientName: string; + readonly scopes: readonly Scope[]; + /** What a call can use now: the grant narrowed to the owner's current role. */ + readonly effectiveScopes: readonly Scope[]; + readonly mode: AgentMode; + readonly effectiveMode: AgentMode; + readonly createdAt: string; + readonly lastUsedAt: string | null; + /** With `all`: whose connection it is. */ + readonly owner?: { readonly userId: string; readonly name: string | null } | undefined; +} + +export function createAgentGrantService(d: AuthDeps) { + const summary = (grant: AgentGrant, role: Role | undefined): AgentGrantSummary => ({ + id: grant.id, + agentId: agentIdForGrant(grant.id), + clientId: grant.clientId, + clientName: grant.clientName, + scopes: grant.scopes, + effectiveScopes: role ? narrow(grant.scopes, roleScopes(role)) : [], + mode: grant.mode, + effectiveMode: role ? clampMode(grant.mode, modeCap(role)) : grant.mode, + createdAt: grant.createdAt, + lastUsedAt: grant.lastUsedAt, + }); + + const list = async (headers: Headers, all: boolean): Promise => { + const who = await d.caller(headers, "sign in to see connected agents"); + const manage = all && roleScopesHold(who.member.role, "agents:manage"); + if (!manage) return (await d.grants.listOf(who.userId)).map((g) => summary(g, who.member.role)); + const grants = await d.grants.listAll(); + const out: AgentGrantSummary[] = []; + // One membership read per owner, not per connection. + const owners = new Map>>(); + for (const g of grants) { + if (!owners.has(g.userId)) owners.set(g.userId, await d.staffStore.member(g.userId)); + const owner = owners.get(g.userId); + out.push({ + ...summary(g, owner?.role), + owner: { userId: g.userId, name: owner?.name ?? null }, + }); + } + return out; + }; + + /** Ends a connection: the grant row first (authoritative), then the provider's consent and tokens. */ + const revoke = async (input: { + headers: Headers; + id: string; + audit: (grant: AgentGrant) => AuditEntry; + }): Promise => { + const who = await d.caller(input.headers, "sign in to disconnect an agent"); + const grant = await d.grants.byId(input.id); + const mine = grant?.userId === who.userId; + const manage = roleScopesHold(who.member.role, "agents:manage"); + // Someone else's connection is invisible without agents:manage (no existence oracle). + if (!grant || grant.revokedAt || (!mine && !manage)) + throw new AuthError("unknown connection", { status: 404, code: "not_found" }); + const audit = AuditLog.statement(input.audit(grant)); + await d.grants.revoke(grant.id, [audit]); + await d.revokeProviderGrant(grant.userId, grant.clientId); + return grant; + }; + + return { + info: (headers: Headers, oauthQuery: string) => consentInfo(d, headers, oauthQuery), + consent: (input: Parameters[1]) => giveConsent(d, input), + list, + revoke, + }; +} + +export type AgentGrantService = ReturnType; + +/** The person-classed actor of a consent or revoke (RFC-0044 Security: approval points). */ +export const personActor = (userId: string, name: string | null) => ({ + kind: "human" as const, + id: actorIdForUser(userId), + ...(name ? { name } : {}), +}); diff --git a/packages/api/src/auth/deps.ts b/packages/api/src/auth/deps.ts index 56ce883f..7943d663 100644 --- a/packages/api/src/auth/deps.ts +++ b/packages/api/src/auth/deps.ts @@ -1,7 +1,10 @@ import { type AgentMode, isMoreAutonomous, modeCap, type Role } from "@cloudhal/agent-runtime"; +import type { JWK } from "jose"; import { StaffStore } from "../staff.ts"; +import { GrantStore, newGrantId } from "./grants.ts"; import { createInstance } from "./instance.ts"; import { type ApiKeyRow, roleScopesHold, type VerifiedApiKey } from "./key-rows.ts"; +import { oauthDeps } from "./oauth-deps.ts"; import { AGENT_KEY_RATE_LIMIT, AuthError, @@ -13,8 +16,12 @@ export function authDeps(options: AuthOptions) { const now = options.now ?? (() => new Date()); const staffStore = new StaffStore(options.sql, now); const keyLimit = options.agentKeyRateLimit ?? AGENT_KEY_RATE_LIMIT; - const instance = createInstance(options, staffStore); + // Marks the server's own consent call to the provider (`AgentGrantService.consent`). + const internal = { serverKey: `${newGrantId()}${newGrantId()}` }; + const instance = createInstance(options, staffStore, internal); + const grants = new GrantStore(options.sql, now); const api = instance.api as unknown as { + getJwks(): Promise<{ keys: JWK[] }>; verifyApiKey(input: { body: { key: string } }): Promise; createApiKey(input: { body: { @@ -51,6 +58,15 @@ export function authDeps(options: AuthOptions) { where: { field: string; value: unknown }[]; }): Promise; delete(input: { model: string; where: { field: string; value: unknown }[] }): Promise; + deleteMany(input: { + model: string; + where: { field: string; value: unknown }[]; + }): Promise; + updateMany(input: { + model: string; + where: { field: string; value: unknown }[]; + update: Record; + }): Promise; }; type AuthContextLike = { adapter: AdapterLike; @@ -111,9 +127,15 @@ export function authDeps(options: AuthOptions) { code: "mode_above_role", }); }; + const oauth = oauthDeps({ options, api, context, grants, staffStore, keyLimit, now }); return { now, + sql: options.sql, + secret: options.secret, + ...oauth, staffStore, + grants, + internal, keyLimit, instance, api, diff --git a/packages/api/src/auth/grants.ts b/packages/api/src/auth/grants.ts new file mode 100644 index 00000000..372aa849 --- /dev/null +++ b/packages/api/src/auth/grants.ts @@ -0,0 +1,218 @@ +/** + * RFC-0044 §5, §7: `agent_grants`, one row per member and OAuth client, written on Studio's + * consent screen. A token is only as good as its grant: the row carries the scopes after + * narrowing by the member's role at consent, the agent mode, an elevation and the revocation, + * and `/mcp` reads it on every call. Each write is one statement or one atomic `batch()` + * (AGENTS.md rule 4). Worker-safe: no Node imports. + */ +import { + type AgentMode, + activeElevation, + isAgentMode, + isKeyScope, + type ModeElevation, + type Scope, +} from "@cloudhal/agent-runtime"; +import type { SqlDriver, SqlStatement } from "@cloudhal/storage"; + +export interface AgentGrant { + readonly id: string; + readonly userId: string; + readonly clientId: string; + readonly clientName: string; + /** Key scopes after narrowing by the installation role at consent (the token's cap). */ + readonly scopes: Scope[]; + readonly mode: AgentMode; + readonly elevation?: ModeElevation | undefined; + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly revokedAt: string | null; +} + +type GrantRow = { + id: string; + user_id: string; + client_id: string; + client_name: string; + scopes: string; + mode: string; + elevation: string | null; + created_at: string; + last_used_at: string | null; + revoked_at: string | null; +}; + +/** `last_used_at` is written at most this often per grant (one write, not one per call). */ +export const LAST_USED_RESOLUTION_MS = 5 * 60_000; + +/** The agent actor id a grant's calls are recorded under (as `agentIdForKey` for keys). */ +export function agentIdForGrant(grantId: string): string { + return `agent_${grantId + .replace(/[^A-Za-z0-9]/g, "") + .slice(0, 32) + .padEnd(6, "0")}`; +} + +function parseScopes(text: string): Scope[] { + try { + const value = JSON.parse(text) as unknown; + return Array.isArray(value) ? value.filter((s): s is Scope => isKeyScope(s)) : []; + } catch { + return []; + } +} + +function parseElevation(text: string | null): ModeElevation | undefined { + if (!text) return undefined; + try { + const raw = JSON.parse(text) as Partial; + if (!isAgentMode(raw.mode) || typeof raw.until !== "string") return undefined; + return { + mode: raw.mode, + until: raw.until, + grantedBy: typeof raw.grantedBy === "string" ? raw.grantedBy : "", + grantedAt: typeof raw.grantedAt === "string" ? raw.grantedAt : "", + ...(typeof raw.reason === "string" ? { reason: raw.reason } : {}), + }; + } catch { + return undefined; + } +} + +function toGrant(row: GrantRow): AgentGrant { + const elevation = parseElevation(row.elevation); + return { + id: row.id, + userId: row.user_id, + clientId: row.client_id, + clientName: row.client_name, + scopes: parseScopes(row.scopes), + mode: isAgentMode(row.mode) ? row.mode : "edits", + ...(elevation ? { elevation } : {}), + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + revokedAt: row.revoked_at, + }; +} + +/** The grant's elevation if one is in force at `now` (ADR-0016, as for a key). */ +export function grantElevation(grant: AgentGrant, now: Date): ModeElevation | undefined { + return grant.elevation ? activeElevation(grant.mode, grant.elevation, now) : undefined; +} + +export interface GrantConsent { + readonly userId: string; + readonly clientId: string; + readonly clientName: string; + readonly scopes: readonly Scope[]; + readonly mode: AgentMode; +} + +export class GrantStore { + readonly #sql: SqlDriver; + readonly #now: () => Date; + constructor(sql: SqlDriver, now: () => Date = () => new Date()) { + this.#sql = sql; + this.#now = now; + } + + async byId(id: string): Promise { + const row = await this.#sql.get("SELECT * FROM agent_grants WHERE id = ?", [id]); + return row ? toGrant(row) : undefined; + } + + /** The live (not revoked) grant of a member for a client. */ + async live(userId: string, clientId: string): Promise { + const row = await this.#sql.get( + "SELECT * FROM agent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL", + [userId, clientId], + ); + return row ? toGrant(row) : undefined; + } + + async listOf(userId: string): Promise { + const rows = await this.#sql.all( + "SELECT * FROM agent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 500", + [userId], + ); + return rows.map(toGrant); + } + + async listAll(): Promise { + const rows = await this.#sql.all( + "SELECT * FROM agent_grants WHERE revoked_at IS NULL ORDER BY created_at DESC LIMIT 500", + ); + return rows.map(toGrant); + } + + /** + * Records a consent: a new grant, or the member's existing row for the client with the new + * scopes and mode (a revoked row comes back to life with a fresh id, so audit rows of the old + * connection keep pointing at the old agent id). `agent_owner` and the audit row go in the same + * batch (RFC-0005 §4), passed in by the caller. + */ + async consent( + input: GrantConsent, + also: (grantId: string) => readonly SqlStatement[] = () => [], + ): Promise { + const now = this.#now().toISOString(); + const existing = await this.#sql.get( + "SELECT * FROM agent_grants WHERE user_id = ? AND client_id = ?", + [input.userId, input.clientId], + ); + const id = existing && !existing.revoked_at ? existing.id : newGrantId(); + const scopes = JSON.stringify([...input.scopes]); + const write: SqlStatement = existing + ? { + // The stored elevation ends with a new consent: a widened connection starts afresh. + sql: "UPDATE agent_grants SET id = ?, client_name = ?, scopes = ?, mode = ?, elevation = NULL, revoked_at = NULL, created_at = CASE WHEN revoked_at IS NULL THEN created_at ELSE ? END WHERE user_id = ? AND client_id = ?", + params: [id, input.clientName, scopes, input.mode, now, input.userId, input.clientId], + } + : { + sql: "INSERT INTO agent_grants (id, user_id, client_id, client_name, scopes, mode, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + params: [id, input.userId, input.clientId, input.clientName, scopes, input.mode, now], + }; + await this.#sql.batch([write, ...also(id)]); + const grant = await this.byId(id); + if (!grant) throw new Error("agent grant vanished after consent"); + return grant; + } + + /** Marks a grant revoked (kept for audit); true when it was live. */ + async revoke(id: string, also: readonly SqlStatement[] = []): Promise { + const before = await this.byId(id); + if (!before || before.revokedAt) return false; + await this.#sql.batch([ + { + sql: "UPDATE agent_grants SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL", + params: [this.#now().toISOString(), id], + }, + ...also, + ]); + return true; + } + + /** `last_used_at`, written only when the stored value is older than the resolution. */ + async touch(grant: AgentGrant): Promise { + const now = this.#now(); + const last = grant.lastUsedAt ? Date.parse(grant.lastUsedAt) : Number.NaN; + if (!Number.isNaN(last) && now.getTime() - last < LAST_USED_RESOLUTION_MS) return; + await this.#sql.run( + "UPDATE agent_grants SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)", + [ + now.toISOString(), + grant.id, + new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString(), + ], + ); + } +} + +/** A grant id: 24 base62 characters from WebCrypto. */ +export function newGrantId(): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = crypto.getRandomValues(new Uint8Array(24)); + let out = ""; + for (const b of bytes) out += alphabet[b % alphabet.length]; + return out; +} diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 3ee544e1..3efbb56c 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -5,6 +5,7 @@ import { APIError, createAuthMiddleware } from "better-auth/api"; import { admin, organization } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; import type { StaffStore } from "../staff.ts"; +import { betterAuthLogger, oauthEndpoints, oauthPlugins } from "./oauth.ts"; import { API_KEY_HEADER, type AuthOptions, @@ -58,9 +59,19 @@ export const INVITATION_FIELDS = { * Typed as Better Auth's base `Auth`: its inferred plugin type cannot be named outside this * module, and callers reach the plugin endpoints through `authDeps`'s typed view of `api`. */ -export function createInstance(options: AuthOptions, staff: StaffStore): Auth { +export function createInstance( + options: AuthOptions, + staff: StaffStore, + internal: InternalKeys, +): Auth { const mode = signUpMode(options); const ipHeader = options.workers ? "cf-connecting-ip" : options.clientAddressHeader; + // RFC-0044 §2: the authorization server, when the runtime has a stable public origin. + const oauth = options.mcpOauth + ? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, (headers) => + constantTimeEqual(headers.get(SERVER_KEY_HEADER) ?? "", internal.serverKey), + ) + : []; const instance = betterAuth({ // biome-ignore lint/suspicious/noExplicitAny: Better Auth accepts several concrete DB handles database: options.database as any, @@ -76,6 +87,8 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { autoSignIn: true, }, telemetry: { enabled: false }, + // Through Cloudhal's logging; an expected refusal (a wrong API key) is not an error (#159). + logger: betterAuthLogger(options.log), // Every signed-in API request reads the session; signed into a cookie for 60 s, it costs no // D1 round trip. A revoked session or a sign-out on another device takes effect within 60 s // (the person's own sign-out clears the cookie at once). @@ -84,6 +97,14 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { // Checked before the endpoint runs (outside Better Auth's write transaction, which would // deadlock a second query on single-connection SQLite). before: createAuthMiddleware(async (ctx) => { + // RFC-0044 §4: consent is given through Studio's screen (`/api/agent-grants/consent`), + // which narrows the scopes by role and writes the grant; a direct call is refused. + if (ctx.path === "/oauth2/consent" && ctx.request) { + const given = ctx.headers?.get(SERVER_KEY_HEADER) ?? ""; + if (!constantTimeEqual(given, internal.serverKey)) + throw new APIError("FORBIDDEN", { message: "consent is given on Studio's screen" }); + return; + } if (!ctx.path.startsWith("/sign-up") || mode !== "first-user") return; const existing = await ctx.context.adapter.count({ model: "user" }); if (existing > 0) @@ -161,11 +182,24 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { // had made `maxRequests` calls in total — and the refusal surfaced as "unauthorized". rateLimit: { enabled: false }, }), + ...oauth, ], }); return instance as unknown as Auth; } +/** Per-instance secrets of the server's own internal calls (never sent to a client). */ +export interface InternalKeys { + /** + * Marks calls the server makes to the provider itself: the consent `AgentGrantService.consent` + * forwards, and server-side client registration (`OauthServer.createClient`). + */ + readonly serverKey: string; +} + +/** The header that carries `InternalKeys.serverKey`. */ +export const SERVER_KEY_HEADER = "x-cloudhal-server-key"; + export function constantTimeEqual(a: string, b: string): boolean { const ea = new TextEncoder().encode(a); const eb = new TextEncoder().encode(b); diff --git a/packages/api/src/auth/oauth-clients.ts b/packages/api/src/auth/oauth-clients.ts new file mode 100644 index 00000000..255c9310 --- /dev/null +++ b/packages/api/src/auth/oauth-clients.ts @@ -0,0 +1,48 @@ +/** + * RFC-0044 §3 (pre-registered clients): a public client created by the server itself. Phase 2 + * has no registration endpoint (CIMD and DCR are Phase 3), so this is how tests, and later + * `cloudhal mcp clients create`, get a `client_id`. The provider's `adminCreateOAuthClient` runs + * with the member's session and the server key, the only caller `clientPrivileges` admits. + */ +import type { AuthDeps } from "./deps.ts"; +import { SERVER_KEY_HEADER } from "./instance.ts"; +import { OAUTH_SCOPES } from "./oauth.ts"; +import type { OauthClientInput } from "./types.ts"; + +function isLoopback(uri: string): boolean { + try { + return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(uri).hostname); + } catch { + return false; + } +} + +export async function createOauthClient( + d: AuthDeps, + input: OauthClientInput, +): Promise<{ clientId: string }> { + if (!d.endpoints) throw new Error("the authorization server is off"); + const headers = new Headers(input.headers); + headers.set(SERVER_KEY_HEADER, d.internal.serverKey); + const api = d.instance.api as unknown as { + adminCreateOAuthClient(input: { + headers: Headers; + body: Record; + }): Promise<{ client_id: string }>; + }; + const created = await api.adminCreateOAuthClient({ + headers, + body: { + client_name: input.name, + redirect_uris: [...input.redirectUris], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + // RFC 8252: a loopback callback is a native app's; `web` needs HTTPS redirect URIs. + application_type: input.redirectUris.every(isLoopback) ? "native" : "web", + scope: OAUTH_SCOPES.join(" "), + resources: [d.endpoints.resource], + }, + }); + return { clientId: created.client_id }; +} diff --git a/packages/api/src/auth/oauth-deps.ts b/packages/api/src/auth/oauth-deps.ts new file mode 100644 index 00000000..60d55fce --- /dev/null +++ b/packages/api/src/auth/oauth-deps.ts @@ -0,0 +1,63 @@ +/** + * RFC-0044 Phase 2: what `authDeps` adds when the deployment runs the authorization server: the + * issuer and resource, the access-token verifier `/mcp` uses (§5), and the provider-side cleanup + * of a revoked connection (§2 "Revocation"). + */ +import type { JWK } from "jose"; +import type { StaffStore } from "../staff.ts"; +import { createAccessTokenVerifier } from "./access-token.ts"; +import type { GrantStore } from "./grants.ts"; +import { oauthEndpoints } from "./oauth.ts"; +import type { TokenPrincipalDeps } from "./token-principal.ts"; +import type { AuthOptions } from "./types.ts"; + +interface AdapterWrites { + deleteMany(input: { model: string; where: { field: string; value: unknown }[] }): Promise; + updateMany(input: { + model: string; + where: { field: string; value: unknown }[]; + update: Record; + }): Promise; +} + +export interface OauthDepsInput { + readonly options: AuthOptions; + readonly api: { getJwks(): Promise<{ keys: JWK[] }> }; + readonly context: () => Promise<{ adapter: AdapterWrites }>; + readonly grants: GrantStore; + readonly staffStore: StaffStore; + readonly keyLimit: { readonly windowMs: number; readonly max: number }; + readonly now: () => Date; +} + +export function oauthDeps(input: OauthDepsInput) { + const { options, api, context, grants, staffStore, keyLimit, now } = input; + const endpoints = options.mcpOauth ? oauthEndpoints(options.baseURL) : undefined; + const tokens: TokenPrincipalDeps | undefined = + endpoints && options.mcpOauth + ? { + verify: createAccessTokenVerifier({ + issuer: endpoints.issuer, + audience: options.mcpOauth.resource, + jwks: () => api.getJwks(), + now, + }), + grants, + staffStore, + keyLimit, + now, + } + : undefined; + /** The provider's consent and refresh tokens for (member, client). */ + const revokeProviderGrant = async (userId: string, clientId: string) => { + if (!endpoints) return; + const { adapter } = await context(); + const where = [ + { field: "clientId", value: clientId }, + { field: "userId", value: userId }, + ]; + await adapter.deleteMany({ model: "oauthConsent", where }); + await adapter.updateMany({ model: "oauthRefreshToken", where, update: { revoked: now() } }); + }; + return { endpoints, tokens, revokeProviderGrant }; +} diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts new file mode 100644 index 00000000..420bfc25 --- /dev/null +++ b/packages/api/src/auth/oauth.ts @@ -0,0 +1,146 @@ +/** + * RFC-0044 §2 (Phase 2): the deployment's OAuth 2.1 authorization server. Better Auth's `mcp()` + * plugin (`@better-auth/mcp`, which composes `@better-auth/oauth-provider` with resource binding) + * and `jwt()` for the signing key, on the same database as Studio's accounts. + * + * - Grants: authorization code with PKCE (S256 only; the plugin refuses `plain`) and refresh + * token. No `client_credentials`: an agent always acts for a member (RFC-0005 I1). + * - Scopes: Cloudhal key scopes verbatim plus `offline_access`; no `openid`, so no OIDC discovery + * and no `id_token` (§2). + * - Tokens: RFC 9068 JWT access tokens (`typ: at+jwt`) for 1 h, `aud` = the `/mcp` resource, `iss` + * = the issuer; refresh tokens for 30 d, rotated on every use with `mcp()`'s 30 s reuse window + * (Decision 2). + * - Registration: none in Phase 2 (CIMD and DCR are Phase 3); clients are created server-side + * (`adminCreateOAuthClient`), and the session-backed client CRUD endpoints are refused. + * + * Worker-safe: the plugins and `jose` use WebCrypto and no `eval`. + */ +import { mcp } from "@better-auth/mcp"; +import { KEY_SCOPES } from "@cloudhal/agent-runtime"; +import type { BetterAuthPlugin } from "better-auth"; +import { jwt } from "better-auth/plugins"; + +/** `offline_access` asks for a refresh token (the plugin issues one only then). */ +export const OFFLINE_ACCESS = "offline_access"; + +/** Every OAuth `scope` value the server knows: key scopes plus `offline_access` (§2). */ +export const OAUTH_SCOPES: readonly string[] = [...KEY_SCOPES, OFFLINE_ACCESS]; + +/** Better Auth's base path, where the issuer lives (`/api/auth`). */ +export const AUTH_BASE_PATH = "/api/auth"; + +/** The Studio routes the authorize endpoint sends the browser to (§4). */ +export const OAUTH_LOGIN_PAGE = "/login"; +export const OAUTH_CONSENT_PAGE = "/oauth/consent"; + +/** The signing algorithm (the `jwt()` plugin's default, Ed25519; WebCrypto on workerd has it). */ +export const ACCESS_TOKEN_ALG = "EdDSA"; + +/** Lifetimes (RFC-0044 §2 table; Decisions 2 and 3). */ +export const ACCESS_TOKEN_TTL_S = 60 * 60; +export const REFRESH_TOKEN_TTL_S = 30 * 24 * 60 * 60; +export const REFRESH_REUSE_WINDOW_S = 30; +/** §2: "authorization code 5 min, single use" (the plugin's default is 10 min). */ +export const AUTHORIZATION_CODE_TTL_S = 5 * 60; + +export interface McpOauthServerOptions { + /** + * The canonical `/mcp` URL tokens are bound to (`aud`, RFC 8707). HTTPS, or HTTP on loopback + * only (the plugin refuses anything else). + */ + readonly resource: string; +} + +/** + * Whether a runtime runs the authorization server (RFC-0044 §10), and why not: an issuer must be + * stable, so it needs the public base URL, over HTTPS or on loopback (the plugin refuses a + * plain-HTTP resource elsewhere, and so do clients). `setting` is `CLOUDHAL_MCP_OAUTH`; `off` + * turns it off. + */ +export function mcpOauthFor( + baseURL: string | undefined, + setting?: string | undefined, +): { readonly mcpOauth: McpOauthServerOptions } | { readonly off: string } { + if (setting?.trim().toLowerCase() === "off") return { off: "CLOUDHAL_MCP_OAUTH=off" }; + if (!baseURL) + return { + off: "no public base URL (--base-url or CLOUDHAL_BASE_URL): an OAuth issuer must be a stable origin", + }; + const url = new URL(baseURL); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.protocol !== "https:" && !loopback) + return { off: `the base URL ${url.origin} is plain HTTP outside loopback; use https` }; + return { mcpOauth: { resource: oauthEndpoints(url.origin).resource } }; +} + +/** Issuer and resource of a deployment, both derived from its public base URL (§10). */ +export function oauthEndpoints(baseURL: string): { issuer: string; resource: string } { + const origin = new URL(baseURL).origin; + return { issuer: `${origin}${AUTH_BASE_PATH}`, resource: `${origin}/mcp` }; +} + +/** + * The plugins `createInstance` adds when the deployment runs the authorization server. `issuer` + * pins the JWT `iss` (and the metadata's `issuer`) to Better Auth's base URL. + */ +export function oauthPlugins( + issuer: string, + options: McpOauthServerOptions, + /** True for the server's own calls (`SERVER_KEY_HEADER`): only they may create clients. */ + fromServer: (headers: Headers) => boolean, +): BetterAuthPlugin[] { + return [ + jwt({ + jwks: { keyPairConfig: { alg: ACCESS_TOKEN_ALG, crv: "Ed25519" } }, + jwt: { issuer }, + // Session payloads are never signed (the plugin's advice next to an OAuth provider): the + // only JWTs this key signs are access tokens. + disableSettingJwtHeader: true, + }), + mcp({ + resource: options.resource, + loginPage: OAUTH_LOGIN_PAGE, + consentPage: OAUTH_CONSENT_PAGE, + scopes: [...OAUTH_SCOPES], + // `scopes_supported` in the AS metadata: all key scopes plus `offline_access` (§2). + advertisedMetadata: { scopes_supported: [...OAUTH_SCOPES] }, + grantTypes: ["authorization_code", "refresh_token"], + accessTokenExpiresIn: ACCESS_TOKEN_TTL_S, + refreshTokenExpiresIn: REFRESH_TOKEN_TTL_S, + refreshTokenReuseInterval: REFRESH_REUSE_WINDOW_S, + codeExpiresIn: AUTHORIZATION_CODE_TTL_S, + // Registration is Phase 3: no DCR, and no member may create or edit clients through the + // session-backed endpoints (they are also fenced in `createApi`); only the server itself + // (`OauthServer.createClient`) creates a pre-registered client. + allowDynamicClientRegistration: false, + allowUnauthenticatedClientRegistration: false, + clientPrivileges: ({ headers, action }) => action === "create" && fromServer(headers), + resourcePrivileges: ({ headers, action }) => action === "link" && fromServer(headers), + }), + ]; +} + +/** A log line from Better Auth, routed through Cloudhal's logging (`AuthOptions.log`). */ +export type AuthLog = (level: "error" | "warn" | "info" | "debug", message: string) => void; + +/** + * Messages that describe an expected refusal the caller already gets as a response, so they are + * not errors of the server: a wrong API key is a 401 (security sweep, #159). + */ +const EXPECTED = ["Failed to validate API key"]; + +/** Better Auth's `logger` option: expected refusals dropped, the rest forwarded. */ +export function betterAuthLogger(log: AuthLog | undefined) { + return { + level: "warn" as const, + log: (level: "error" | "warn" | "info" | "debug", message: string, ...args: unknown[]) => { + if (EXPECTED.some((m) => message.includes(m))) return; + // Only an error's message: arguments may carry request data, never credentials in logs. + const detail = args.find((a): a is Error => a instanceof Error)?.message; + const line = detail ? `${message}: ${detail}` : message; + if (log) log(level, line); + else if (level === "error") console.error(`[Better Auth] ${line}`); + else console.warn(`[Better Auth] ${line}`); + }, + }; +} diff --git a/packages/api/src/auth/principal.ts b/packages/api/src/auth/principal.ts index 1ea7b4c6..20ed88ff 100644 --- a/packages/api/src/auth/principal.ts +++ b/packages/api/src/auth/principal.ts @@ -10,6 +10,7 @@ import type { Actor } from "@cloudhal/core-contracts"; import type { Context } from "hono"; import { HTTPException } from "hono/http-exception"; import type { PrincipalResolver } from "../context.ts"; +import { accessTokenOf } from "./access-token.ts"; import type { AuthDeps } from "./deps.ts"; import { actorIdForUser, @@ -20,6 +21,7 @@ import { personRef, storedScopes, } from "./key-rows.ts"; +import { tokenPrincipal } from "./token-principal.ts"; import { agentKeyFrom } from "./types.ts"; export function createPrincipalResolver(d: AuthDeps): PrincipalResolver { @@ -27,6 +29,10 @@ export function createPrincipalResolver(d: AuthDeps): PrincipalResolver { const resolvePrincipal: PrincipalResolver = async ( c: Context, ): Promise => { + // RFC-0044 §5: a bearer JWT with `typ: at+jwt` is an OAuth access token, never an agent key + // (keys are opaque strings); it never falls through to the key lookup. + const token = accessTokenOf(c.req.raw.headers); + if (token) return tokenPrincipal(d.tokens, token); const key = agentKeyFrom(c.req.raw.headers, c.req.path); if (key) { const verified = await api.verifyApiKey({ body: { key } }); diff --git a/packages/api/src/auth/token-principal.ts b/packages/api/src/auth/token-principal.ts new file mode 100644 index 00000000..1cb085bb --- /dev/null +++ b/packages/api/src/auth/token-principal.ts @@ -0,0 +1,80 @@ +/** + * RFC-0044 §5: an OAuth access token becomes the same `Principal` an agent key produces, so the + * policy engine, the audit log, approvals, modes, elevations and `forSite` do not know which one + * they got (invariant 5). The invariants, as tests cite them: + * + * 1. Never more than a key of the same member would hold: key scopes only, capped by the grant + * (narrowed at consent) and narrowed again to the member's current role on every call. + * 2. No membership, no principal (401 with the challenge), whatever the token says. + * 3. `aud` must be this deployment's `/mcp` resource and `iss` its issuer (`access-token.ts`). + * 4. A revoked grant refuses the token at its next call; a removed member refuses every grant. + * 6. A token never sets `local`. + */ +import { + clampMode, + isMoreAutonomous, + modeCap, + narrow, + type Principal, + roleScopes, +} from "@cloudhal/agent-runtime"; +import { HTTPException } from "hono/http-exception"; +import type { StaffStore } from "../staff.ts"; +import type { AccessTokenVerifier } from "./access-token.ts"; +import { agentIdForGrant, type GrantStore, grantElevation } from "./grants.ts"; +import { personRef } from "./key-rows.ts"; + +export interface TokenPrincipalDeps { + readonly verify: AccessTokenVerifier; + readonly grants: GrantStore; + readonly staffStore: StaffStore; + readonly keyLimit: { readonly windowMs: number; readonly max: number }; + readonly now: () => Date; +} + +/** + * The principal of a bearer access token, or `undefined` (→ 401 `invalid_token`); always + * `undefined` on a deployment without the authorization server (`d` absent). + */ +export async function tokenPrincipal( + d: TokenPrincipalDeps | undefined, + token: string, +): Promise { + if (!d) return undefined; + const claims = await d.verify(token); + if (!claims) return undefined; + const grant = await d.grants.live(claims.sub, claims.clientId); + if (!grant) return undefined; // inv. 4: revoked, or consent never went through Studio + // The same fixed window as a key (`rate_limits`), per connection. + if (!(await d.staffStore.hit(`cloudhal:grant:${grant.id}`, d.keyLimit.windowMs, d.keyLimit.max))) + throw new HTTPException(429, { + res: Response.json( + { + error: "rate_limited", + message: `this connection made more than ${d.keyLimit.max} requests in ${Math.round(d.keyLimit.windowMs / 1000)} s; wait and try again`, + }, + { status: 429, headers: { "retry-after": String(Math.ceil(d.keyLimit.windowMs / 1000)) } }, + ), + }); + const member = await d.staffStore.member(claims.sub); + if (!member) return undefined; // inv. 2 + // inv. 1: what the token says, capped by the grant (`narrow` keeps a grant's `:own` variant), + // then by the member's role now. + const keyScopes = narrow(claims.scopes, grant.scopes); + const scopes = narrow(keyScopes, roleScopes(member.role)); + const cap = modeCap(member.role); + const base = clampMode(grant.mode, cap); + const raw = grantElevation(grant, d.now()); + const elevation = raw && !isMoreAutonomous(raw.mode, cap) ? raw : undefined; + await d.grants.touch(grant); + return { + actor: { kind: "agent", id: agentIdForGrant(grant.id), name: grant.clientName }, + scopes, + mode: elevation?.mode ?? base, + ...(elevation ? { elevation: { from: base, until: elevation.until } } : {}), + grantId: grant.id, + // RFC-0034 A2: `forSite` narrows these per site, as for keys. + keyScopes, + person: personRef(member), + }; +} diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index fe9fddbf..9c4871c5 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -9,6 +9,8 @@ import type { SqlDriver } from "@cloudhal/storage"; import type { Auth } from "better-auth"; import type { ActorResolver, PrincipalResolver } from "../context.ts"; import type { StaffStore } from "../staff.ts"; +import type { AgentGrantService } from "./agent-grants.ts"; +import type { AuthLog, McpOauthServerOptions } from "./oauth.ts"; export interface AuthOptions { /** better-sqlite3 `Database`, `node:sqlite` `DatabaseSync`, or a Cloudflare `D1Database` binding. */ @@ -64,6 +66,15 @@ export interface AuthOptions { }; /** Clock for time-boxed elevations and invites (tests). */ readonly now?: () => Date; + /** + * RFC-0044 Phase 2: run the OAuth 2.1 authorization server for remote MCP clients. Its issuer is + * `baseURL`'s origin plus `/api/auth`, so `baseURL` must be the stable public origin (on Node, + * `cloudhal studio --base-url`); `resource` is the `/mcp` URL tokens are bound to. Unset, the + * deployment has no authorization server and `/mcp` takes keys and sessions only. + */ + readonly mcpOauth?: McpOauthServerOptions | undefined; + /** Where Better Auth's log lines go (default: the console, errors and warnings only). */ + readonly log?: AuthLog | undefined; /** Requests one API key may make per window (default 600 per minute); see `resolvePrincipal`. */ readonly agentKeyRateLimit?: { readonly windowMs: number; readonly max: number }; } @@ -130,6 +141,31 @@ export interface CloudhalAuth { readonly staff: StaffService; /** The underlying Better Auth instance (server API: `auth.api.*`). */ readonly instance: Auth; + /** + * RFC-0044 Phase 2, with `AuthOptions.mcpOauth`: the authorization server. Pass it to + * `createApi` as `oauthServer` and its `issuer`/`resource` as `mcp.oauth`. + */ + readonly oauth?: OauthServer | undefined; +} + +/** What `createApi` needs of the authorization server (RFC-0044 §2, §4). */ +export interface OauthServer { + readonly issuer: string; + readonly resource: string; + readonly grants: AgentGrantService; + /** The RFC 8414 authorization server metadata document. */ + readonly metadata: () => Promise>; + /** + * Server-side only (no HTTP route): a pre-registered public client, created as the signed-in + * member `headers` belong to. Phase 2 uses it in tests; Phase 3 adds CIMD, DCR and a CLI. + */ + readonly createClient: (input: OauthClientInput) => Promise<{ clientId: string }>; +} + +export interface OauthClientInput { + readonly headers: Headers; + readonly name: string; + readonly redirectUris: readonly string[]; } export interface AgentKeySummary { diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 507ee2d4..3d199f7c 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -14,6 +14,7 @@ import type { CommentsDeps } from "@cloudhal/mcp-server/comments"; import type { SchedulesDeps } from "@cloudhal/mcp-server/schedules"; import type { MediaToolOptions, RenderDeps } from "@cloudhal/mcp-server/tools"; import type { Context } from "hono"; +import type { OauthServer } from "./auth/types.ts"; import type { AgentKeyService, StaffService } from "./auth.ts"; import type { BuildHub } from "./build.ts"; import type { MailSender } from "./mail.ts"; @@ -73,6 +74,12 @@ export interface ApiDeps { readonly signUp?: "first-user" | "closed" | "open"; /** Mounts `/api/agent-keys` (create/list/delete API keys for agents). */ readonly agentKeys?: AgentKeyService; + /** + * RFC-0044 Phase 2: the OAuth authorization server (`CloudhalAuth.oauth`). Mounts its RFC 8414 + * metadata at the well-known paths, Studio's consent API and `/api/agent-grants`. Set + * `mcp.oauth` from the same issuer and resource so discovery points at it. + */ + readonly oauthServer?: OauthServer | undefined; /** Extra fetch handler mounted at `/api/auth/*` (Better Auth). */ readonly authHandler?: (request: Request) => Promise; /** diff --git a/packages/api/src/oauth-server.ts b/packages/api/src/oauth-server.ts new file mode 100644 index 00000000..3f84fda5 --- /dev/null +++ b/packages/api/src/oauth-server.ts @@ -0,0 +1,135 @@ +/** + * RFC-0044 Phase 2, the HTTP side of the authorization server that is not Better Auth's own + * routing: + * + * - §2: the RFC 8414 metadata at the path-inserted well-known URL + * (`/.well-known/oauth-authorization-server/api/auth`) and at the root alias (Decision 6), both + * outside `/api` and cacheable like the protected resource metadata; + * - §4: Studio's consent API and `/api/agent-grants` (list and revoke; mode changes and + * elevations are Phase 3's "Connected agents"); + * - the fence: provider endpoints that are not part of the code flow answer 404 over HTTP. + */ +import { isAgentMode } from "@cloudhal/agent-runtime"; +import { trimEndChar } from "@cloudhal/core-ts/names"; +import { type Context, Hono } from "hono"; +import { personActor } from "./auth/agent-grants.ts"; +import type { OauthServer } from "./auth/types.ts"; +import type { Env } from "./context.ts"; +import { badRequest, unauthorized } from "./errors.ts"; +import { param } from "./params.ts"; + +/** RFC 8414 §3: the well-known path; the path-inserted form appends the issuer's path. */ +export const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server"; + +/** + * The provider endpoints a client or Studio reaches over HTTP. Everything else under + * `/api/auth/oauth2/` (client and consent CRUD, introspection, userinfo, registration until + * Phase 3) and the `jwt()` plugin's session-token endpoint `/api/auth/token` answer 404, like the + * organization, admin and api-key endpoints (RFC-0005 I3). Consent goes through + * `/api/agent-grants/consent`, never the provider's own endpoint. + */ +const OPEN_OAUTH_PATHS = new Set([ + "/api/auth/oauth2/authorize", + "/api/auth/oauth2/token", + "/api/auth/oauth2/revoke", +]); + +/** True for a Better Auth path of the authorization server that HTTP callers may not reach. */ +export function fencedOauthPath(pathname: string): boolean { + const path = trimEndChar(pathname, "/"); + if (path === "/api/auth/token") return true; + return path.startsWith("/api/auth/oauth2") && !OPEN_OAUTH_PATHS.has(path); +} + +/** Serves the AS metadata at the path-inserted URL and the root alias (`ROUTE_SCOPES`: public). */ +export function mountAuthorizationServerMetadata(app: Hono, server: OauthServer): void { + const handler = async (c: Context) => + c.body(JSON.stringify(await server.metadata()), 200, { + "content-type": "application/json", + "cache-control": "public, max-age=3600", + }); + const issuerPath = trimEndChar(new URL(server.issuer).pathname, "/"); + app.get(`${AUTHORIZATION_SERVER_PATH}${issuerPath}`, handler); + app.get(AUTHORIZATION_SERVER_PATH, handler); +} + +/** RFC-0044 Phase 2: the metadata documents and `/api/agent-grants`. */ +export function mountOauthServer(app: Hono, server: OauthServer): void { + mountAuthorizationServerMetadata(app, server); + app.route("/api/agent-grants", agentGrantRoutes(server)); +} + +/** The signed authorization request (`oauth_query`), as the consent page received it. */ +function requestOf(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value.length > 8192) + throw badRequest("request is required"); + return value.startsWith("?") ? value.slice(1) : value; +} + +/** + * `/api/agent-grants` (people only, `member` in `ROUTE_SCOPES`; agents are refused there): + * + * - `GET /consent?request=` → `ConsentInfo` for Studio's consent screen + * - `POST /consent` {request, accept, scopes, mode} → `{redirect}`: the URL the browser goes to + * (the client's redirect URI with the code, or with `access_denied`); audited `connect_agent` + * - `GET /` → the caller's connections; `?all=1` every one with its owner (`agents:manage`) + * - `DELETE /:id` → 204 (own connections, or any with `agents:manage`; audited `revoke_agent`) + */ +export function agentGrantRoutes(server: OauthServer): Hono { + const grants = server.grants; + return new Hono() + .get("/consent", async (c) => + c.json(await grants.info(c.req.raw.headers, requestOf(c.req.query("request")))), + ) + .post("/consent", async (c) => { + const body = ((await c.req.json().catch(() => ({}))) ?? {}) as Record; + const actor = c.get("actor"); + const person = c.get("principal")?.person; + if (!actor || !person) throw unauthorized(); + if (typeof body.accept !== "boolean") throw badRequest("accept must be true or false"); + const scopes = Array.isArray(body.scopes) + ? body.scopes.filter((s): s is string => typeof s === "string") + : []; + const mode = body.mode ?? "edits"; + if (!isAgentMode(mode)) throw badRequest("unknown mode"); + const now = c.get("now"); + const result = await grants.consent({ + headers: c.req.raw.headers, + oauthQuery: requestOf(body.request), + accept: body.accept, + scopes, + mode, + audit: ({ agentId, scopes: kept }) => ({ + at: now().toISOString(), + actor: personActor(person.userId, person.name ?? null), + tool: "connect_agent", + decision: "allow", + outcome: "ok", + mode, + reason: `connected ${agentId}`, + input: { agentId, scopes: kept, mode }, + }), + }); + return c.json({ redirect: result.redirect }); + }) + .get("/", async (c) => c.json(await grants.list(c.req.raw.headers, c.req.query("all") === "1"))) + .delete("/:id", async (c) => { + const person = c.get("principal")?.person; + if (!person) throw unauthorized(); + const now = c.get("now"); + await grants.revoke({ + headers: c.req.raw.headers, + id: param(c, "id"), + audit: (grant) => ({ + at: now().toISOString(), + actor: personActor(person.userId, person.name ?? null), + tool: "revoke_agent", + decision: "allow", + outcome: "ok", + reason: `${grant.clientName} disconnected`, + input: { grantId: grant.id, clientId: grant.clientId, owner: grant.userId }, + }), + }); + return c.body(null, 204); + }); +} diff --git a/packages/api/src/playground.ts b/packages/api/src/playground.ts index 79779865..4c75c6e5 100644 --- a/packages/api/src/playground.ts +++ b/packages/api/src/playground.ts @@ -58,11 +58,16 @@ const DENY: readonly (readonly [string, string, PlaygroundBlock])[] = [ ["ALL", "/api/invites/*", "people"], ["ALL", "/api/agent-keys", "keys"], ["ALL", "/api/agent-keys/*", "keys"], + // RFC-0044 Decision 10: the playground stays out of OAuth. + ["ALL", "/api/agent-grants", "keys"], + ["ALL", "/api/agent-grants/*", "keys"], ["ALL", "/mcp", "mcp"], // RFC-0044 decision 10: a playground has one fixed principal and no authorization server, so // its MCP never speaks OAuth discovery (the documents are only mounted with `mcp.oauth`). ["GET", "/.well-known/oauth-protected-resource", "mcp"], ["GET", "/.well-known/oauth-protected-resource/mcp", "mcp"], + ["GET", "/.well-known/oauth-authorization-server", "mcp"], + ["GET", "/.well-known/oauth-authorization-server/api/auth", "mcp"], ["ALL", `${S}/approvals`, "agent"], ["ALL", `${S}/approvals/*`, "agent"], ["ALL", `${S}/ai/*`, "agent"], diff --git a/packages/api/src/routes/instance.ts b/packages/api/src/routes/instance.ts index d7b724b6..cd76439f 100644 --- a/packages/api/src/routes/instance.ts +++ b/packages/api/src/routes/instance.ts @@ -41,6 +41,8 @@ export interface InstanceInfo { features: { agentKeys: boolean; mcp: boolean; + /** RFC-0044: remote MCP clients connect by URL through OAuth (the authorization server runs). */ + mcpOauth: boolean; activity: boolean; previewLinks: boolean; deployTransfer: boolean; diff --git a/packages/api/src/routes/members.ts b/packages/api/src/routes/members.ts index 37a731da..95a041c4 100644 --- a/packages/api/src/routes/members.ts +++ b/packages/api/src/routes/members.ts @@ -209,6 +209,7 @@ export function memberRoutes(options: { // 2. sessions, 3. keys, 4. their pending invites, 5. their agents' pending requests await staff.revokeSessions(target.userId); const keysDisabled = await staff.store.disableKeysOf(target.userId); + await staff.store.revokeGrantsOf(target.userId); // RFC-0044: their OAuth connections too const invitesCanceled = await staff.store.cancelInvitesBy(target.userId, person.userId); const agents = await staff.store.agentsOf(target.userId); const requestsDenied = diff --git a/packages/api/src/scopes.ts b/packages/api/src/scopes.ts index c850b4f1..2df59cdf 100644 --- a/packages/api/src/scopes.ts +++ b/packages/api/src/scopes.ts @@ -44,6 +44,9 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] // never sees them, but every mounted route is declared). ["GET", "/.well-known/oauth-protected-resource", "public"], ["GET", "/.well-known/oauth-protected-resource/mcp", "public"], + // RFC-0044 §2: authorization server metadata (served only with `oauthServer`). + ["GET", "/.well-known/oauth-authorization-server", "public"], + ["GET", "/.well-known/oauth-authorization-server/api/auth", "public"], ["GET", "/api/me", "signed-in"], // operator (deployment token) @@ -110,6 +113,11 @@ export const ROUTE_SCOPES: readonly (readonly [Method, string, RouteRequirement] ["DELETE", "/api/agent-keys/:id", "member"], ["POST", "/api/agent-keys/:id/elevation", "member"], ["DELETE", "/api/agent-keys/:id/elevation", "member"], + // RFC-0044 §4: OAuth connections (consent screen, list, revoke). + ["GET", "/api/agent-grants/consent", "member"], + ["POST", "/api/agent-grants/consent", "member"], + ["GET", "/api/agent-grants", "member"], + ["DELETE", "/api/agent-grants/:id", "member"], // members, invites, password resets (RFC-0005 §10.1) ["GET", "/api/members", "member"], diff --git a/packages/api/src/staff.ts b/packages/api/src/staff.ts index 89b81470..81eb56a7 100644 --- a/packages/api/src/staff.ts +++ b/packages/api/src/staff.ts @@ -311,6 +311,15 @@ export class StaffStore { return rows.map((r) => r.agent_id); } + /** Revokes every OAuth connection of a person (removal; RFC-0044 §5 inv. 4); kept for audit. */ + async revokeGrantsOf(userId: string): Promise { + const r = await this.#sql.run( + "UPDATE agent_grants SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + [this.#stamp(), userId], + ); + return r.changes; + } + /** Disables every key of a person (removal, RFC-0005 §3); kept for audit, deletable. */ async disableKeysOf(userId: string): Promise { const r = await this.#sql.run( diff --git a/packages/api/src/testing-mcp-oauth-server.ts b/packages/api/src/testing-mcp-oauth-server.ts new file mode 100644 index 00000000..99a55c87 --- /dev/null +++ b/packages/api/src/testing-mcp-oauth-server.ts @@ -0,0 +1,416 @@ +/** + * Shared suite for RFC-0044 Phase 2 (§2, §4, §5, §8): the authorization server, Studio's consent + * API and the token path on `/mcp`, on better-sqlite3 and Miniflare D1. Discovery leads to the + * AS metadata; the code flow with PKCE (S256 only); the token on `/mcp`; refresh rotation with + * its reuse window; revocation; `aud` and `iss` bound to the deployment; step-up then a new + * consent; scopes narrowed by the member's installation role. The client is pre-registered + * server-side (`OauthServer.createClient`): registration (CIMD, DCR) is Phase 3. + */ +import { narrow, roleScopes, scopesForMode } from "@cloudhal/agent-runtime"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { agentIdForGrant } from "./auth/grants.ts"; +import { OAUTH_SCOPES } from "./auth/oauth.ts"; +import { + authorize, + connect, + consent, + consentRequest, + cookieOf, + deploy, + ISSUER, + type Json, + mcpCall, + mcpClient, + ORIGIN, + REDIRECT, + RESOURCE, + setupServer, + token, +} from "./testing-oauth-harness.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +const READ = "site:read content:read"; + +export function describeMcpOauthServer(label: string, backend: RolesBackend): void { + describe(`MCP OAuth authorization server: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + afterEach(() => { + vi.useRealTimers(); + clearAccessTokenKeyCache(); + }); + + it("discovery leads from the 401 to the authorization server metadata", async () => { + const s = await setupServer(backend); + const first = await s.app.request(`${ORIGIN}/mcp`, mcpCall("", "tools/list")); + expect(first.status).toBe(401); + const prmUrl = /resource_metadata="([^"]+)"/.exec( + first.headers.get("www-authenticate") ?? "", + )?.[1]; + expect(prmUrl).toBe(`${ORIGIN}/.well-known/oauth-protected-resource/mcp`); + const prm = (await (await s.app.request(prmUrl as string)).json()) as Json; + expect(prm.resource).toBe(RESOURCE); + expect(prm.authorization_servers).toEqual([ISSUER]); + // RFC 8414 §3.1: path insertion of the issuer's path. + const metaRes = await s.app.request( + `${ORIGIN}/.well-known/oauth-authorization-server/api/auth`, + ); + expect(metaRes.status).toBe(200); + expect(metaRes.headers.get("cache-control")).toContain("max-age=3600"); + const meta = (await metaRes.json()) as Json; + expect(meta.issuer).toBe(ISSUER); + expect(meta.authorization_endpoint).toBe(`${ISSUER}/oauth2/authorize`); + expect(meta.token_endpoint).toBe(`${ISSUER}/oauth2/token`); + expect(meta.code_challenge_methods_supported).toEqual(["S256"]); + expect(meta.authorization_response_iss_parameter_supported).toBe(true); + expect(meta.grant_types_supported).toEqual(["authorization_code", "refresh_token"]); + expect(meta.scopes_supported).toEqual(OAUTH_SCOPES); + // Phase 2: no registration endpoint yet (CIMD and DCR are Phase 3). + expect(meta.registration_endpoint).toBeUndefined(); + // Decision 6: the root alias returns the same document. + const alias = await s.app.request(`${ORIGIN}/.well-known/oauth-authorization-server`); + expect(await alias.json()).toEqual(meta); + }); + + it("runs the code flow with PKCE, and the token works on /mcp", async () => { + const s = await setupServer(backend); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + expect(start.location?.pathname).toBe("/oauth/consent"); + const request = consentRequest(start.location as URL); + const info = await s.call( + "GET", + `/api/agent-grants/consent?request=${encodeURIComponent(request)}`, + { cookie: s.owner.cookie }, + ); + expect(info.status, JSON.stringify(info.body)).toBe(200); + expect(info.body.client).toEqual({ id: s.clientId, name: "Test Agent" }); + expect(info.body.redirect).toEqual({ host: "127.0.0.1:33418", loopback: true }); + expect(info.body.loopbackOnly).toBe(true); + expect(info.body.scopes).toEqual([ + { requested: "site:read", granted: "site:read" }, + { requested: "content:read", granted: "content:read" }, + ]); + expect(info.body.modes).toEqual(["plan", "manual", "edits", "auto"]); + expect(info.body.defaultMode).toBe("edits"); + + const { access, refresh } = await connect(s, { cookie: s.owner.cookie, scope: READ }); + expect(access.split(".")).toHaveLength(3); + expect(refresh).toBeUndefined(); // no offline_access, no refresh token + const agent = await mcpClient(s.app, access); + const { tools } = await agent.listTools(); + expect(tools.map((t) => t.name)).toContain("get_mode"); + const mode = await agent.callTool({ name: "get_mode", arguments: {} }); + expect(mode.isError).toBeFalsy(); + + const grants = await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie }); + const [grant] = grants.body as unknown as Json[]; + expect(grant).toMatchObject({ + clientId: s.clientId, + clientName: "Test Agent", + scopes: ["site:read", "content:read"], + mode: "edits", + }); + const agentId = agentIdForGrant(grant?.id as string); + expect( + await s.sql.get("SELECT user_id FROM agent_owner WHERE agent_id = ?", [agentId]), + ).toEqual({ user_id: s.owner.userId }); + const rows = await s.audit.list({ tool: "connect_agent" }); + expect(rows[0]).toMatchObject({ decision: "allow", actor: { kind: "human" } }); + }); + + it("sends a signed-out member to sign in, then on to the consent screen", async () => { + const s = await setupServer(backend); + const start = await authorize(s, { cookie: "", scope: READ }); + expect(start.location?.pathname).toBe("/login"); + // Studio's login page posts the signed request with the credentials (`oauth_query`). + const signIn = await s.call("POST", "/api/auth/sign-in/email", { + body: { + email: "owner@example.com", + password: "correct horse battery staple", + oauth_query: (start.location as URL).search.slice(1), + }, + }); + expect(signIn.status, JSON.stringify(signIn.body)).toBe(200); + const next = new URL(signIn.body.url as string, ORIGIN); + expect(next.pathname).toBe("/oauth/consent"); + const info = await s.call( + "GET", + `/api/agent-grants/consent?request=${encodeURIComponent(next.search.slice(1))}`, + { cookie: cookieOf(signIn.res) }, + ); + expect(info.status, JSON.stringify(info.body)).toBe(200); + }); + + it("refuses a tampered authorization request on the consent screen", async () => { + const s = await setupServer(backend); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const tampered = consentRequest(start.location as URL).replace( + "scope=site%3Aread", + "scope=site%3Apublish+site%3Aread", + ); + const info = await s.call( + "GET", + `/api/agent-grants/consent?request=${encodeURIComponent(tampered)}`, + { cookie: s.owner.cookie }, + ); + expect(info.status).toBe(400); + const posted = await consent(s, s.owner.cookie, tampered, { scopes: ["site:publish"] }); + expect(posted.status).toBe(400); + }); + + it("refuses an authorization request without PKCE, or with plain", async () => { + const s = await setupServer(backend); + for (const challengeMethod of [null, "plain"]) { + const r = await authorize(s, { cookie: s.owner.cookie, scope: READ, challengeMethod }); + expect(r.location?.origin + (r.location?.pathname ?? "")).toBe(REDIRECT); + expect(r.location?.searchParams.get("error")).toBe("invalid_request"); + } + }); + + it("sends the browser back with access_denied when the member denies", async () => { + const s = await setupServer(backend); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const denied = await consent(s, s.owner.cookie, consentRequest(start.location as URL), { + accept: false, + scopes: [], + }); + expect(denied.status).toBe(200); + expect(new URL(denied.body.redirect as string).searchParams.get("error")).toBe( + "access_denied", + ); + expect((await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })).body).toEqual( + [], + ); + }); + + it("never lets a client or a page reach the provider's consent and client endpoints", async () => { + const s = await setupServer(backend); + for (const path of [ + "/api/auth/oauth2/consent", + "/api/auth/oauth2/create-client", + "/api/auth/oauth2/register", + "/api/auth/oauth2/get-consents", + "/api/auth/oauth2/introspect", + "/api/auth/token", + ]) { + const r = await s.call("POST", path, { cookie: s.owner.cookie, body: {} }); + expect(r.status, path).toBe(404); + } + }); + + it("rotates refresh tokens, with a 30 s reuse window, then revokes the family", async () => { + const s = await setupServer(backend); + const { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + expect(refresh).toBeTruthy(); + const first = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.refresh_token).not.toBe(refresh); + // A concurrent retry within the window gets the same response (Decision 2). + const retry = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(retry.status).toBe(200); + expect(retry.body.refresh_token).toBe(first.body.refresh_token); + // Beyond the window the rotated token is a replay: refused, and the family revoked. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 31_000); + const replay = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(replay.status).toBe(400); + expect(replay.body.error).toBe("invalid_grant"); + const next = await token(s, { + grant_type: "refresh_token", + refresh_token: first.body.refresh_token as string, + }); + expect(next.body.error).toBe("invalid_grant"); + }); + + it("refuses a revoked connection at its next call, and its refresh token (inv. 4)", async () => { + const s = await setupServer(backend); + const { access, refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + const [grant] = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + const del = await s.call("DELETE", `/api/agent-grants/${grant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + expect(del.status, JSON.stringify(del.body)).toBe(204); + const res = await s.app.request(`${ORIGIN}/mcp`, mcpCall(access, "tools/list")); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toContain('error="invalid_token"'); + const again = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(again.body.error).toBe("invalid_grant"); + expect((await s.audit.list({ tool: "revoke_agent" }))[0]).toMatchObject({ + decision: "allow", + }); + }); + + it("refuses a removed member's token (inv. 2)", async () => { + const s = await setupServer(backend); + const editor = await s.join("editor"); + const { access } = await connect(s, { cookie: editor.cookie, scope: READ }); + await (await mcpClient(s.app, access)).listTools(); + const removed = await s.call("DELETE", `/api/members/${editor.userId}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + expect(removed.status, JSON.stringify(removed.body)).toBe(200); + expect((await s.app.request(`${ORIGIN}/mcp`, mcpCall(access, "tools/list"))).status).toBe( + 401, + ); + }); + + it("refuses a token for another resource (aud) or from another issuer (iss) (inv. 3)", async () => { + const s = await setupServer(backend); + const { access } = await connect(s, { cookie: s.owner.cookie, scope: READ }); + // Same database, same signing key; only the audience differs. + const otherAud = await deploy(s.backend, { resource: `${ORIGIN}/other/mcp` }); + const a = await otherAud.app.request(`${ORIGIN}/mcp`, mcpCall(access, "tools/list")); + expect(a.status).toBe(401); + expect(a.headers.get("www-authenticate")).toContain('error="invalid_token"'); + // Same key and the same resource, another issuer. + const otherIss = await deploy(s.backend, { + baseURL: "http://127.0.0.1:8787", + resource: RESOURCE, + }); + const i = await otherIss.app.request(`${ORIGIN}/mcp`, mcpCall(access, "tools/list")); + expect(i.status).toBe(401); + // The deployment itself still accepts it. + await (await mcpClient(s.app, access)).listTools(); + }); + + it("challenges a missing scope (step-up), then a new consent widens the connection", async () => { + const s = await setupServer(backend); + const { access } = await connect(s, { cookie: s.owner.cookie, scope: READ }); + const call = mcpCall(access, "tools/call", { + name: "publish", + arguments: { siteId: s.siteId }, + }); + const denied = await s.app.request(`${ORIGIN}/mcp`, call); + expect(denied.status).toBe(403); + expect(denied.headers.get("www-authenticate")).toContain('error="insufficient_scope"'); + expect(denied.headers.get("www-authenticate")).toContain('scope="site:publish"'); + // The client re-authorizes with the union; the screen pre-checks what was granted. + const union = `${READ} site:publish`; + const start = await authorize(s, { cookie: s.owner.cookie, scope: union }); + expect(start.location?.pathname).toBe("/oauth/consent"); + const info = await s.call( + "GET", + `/api/agent-grants/consent?request=${encodeURIComponent(consentRequest(start.location as URL))}`, + { cookie: s.owner.cookie }, + ); + expect(info.body.existing).toEqual({ scopes: ["site:read", "content:read"], mode: "edits" }); + const widened = await connect(s, { cookie: s.owner.cookie, scope: union }); + expect( + ( + await s.app.request( + `${ORIGIN}/mcp`, + mcpCall(widened.access, "tools/call", { + name: "publish", + arguments: { siteId: s.siteId }, + }), + ) + ).status, + ).not.toBe(403); + const published = await (await mcpClient(s.app, widened.access)).callTool({ + name: "publish", + arguments: { siteId: s.siteId }, + }); + expect(JSON.stringify(published)).not.toContain("insufficient_scope"); + }); + + it("skips the screen for a subset of what was granted (Decision 8)", async () => { + const s = await setupServer(backend); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const again = await authorize(s, { cookie: s.owner.cookie, scope: "site:read" }); + expect(again.location?.origin + (again.location?.pathname ?? "")).toBe(REDIRECT); + expect(again.location?.searchParams.get("code")).toBeTruthy(); + }); + + it("narrows the requested scopes by the member's installation role at consent", async () => { + const s = await setupServer(backend); + const requested = scopesForMode("edits"); + // An installation Contributor who is an Editor on the site: consent caps by the installation + // role, which the step-up cap of #173 relies on; the site role only narrows further. + const contributor = await s.join("contributor", { sites: { [s.siteId]: "editor" } }); + const start = await authorize(s, { + cookie: contributor.cookie, + scope: [...requested, "site:publish"].join(" "), + }); + const request = consentRequest(start.location as URL); + const info = await s.call( + "GET", + `/api/agent-grants/consent?request=${encodeURIComponent(request)}`, + { cookie: contributor.cookie }, + ); + const scopes = info.body.scopes as { requested: string; granted: string | null }[]; + expect(scopes.find((x) => x.requested === "content:write")?.granted).toBe( + "content:draft:own", + ); + expect(scopes.find((x) => x.requested === "site:publish")?.granted).toBeNull(); + expect(info.body.modes).toEqual(["plan", "manual", "edits"]); + // `auto` is above a Contributor's cap, as for a key. + const tooMuch = await consent(s, contributor.cookie, request, { + scopes: requested, + mode: "auto", + }); + expect(tooMuch.status).toBe(400); + expect(tooMuch.body.error).toBe("mode_above_role"); + const { access } = await connect(s, { + cookie: contributor.cookie, + scope: [...requested, "site:publish"].join(" "), + keep: [...requested, "site:publish"], + }); + const [grant] = (await s.call("GET", "/api/agent-grants", { cookie: contributor.cookie })) + .body as unknown as Json[]; + const expected = narrow([...requested, "site:publish"], roleScopes("contributor")); + expect(grant?.scopes).toEqual(expected); + expect(grant?.scopes).not.toContain("content:write"); + // The token path: never more than the installation role gives (inv. 1). + const raw = await s.app.request( + `${ORIGIN}/mcp`, + mcpCall(access, "tools/call", { name: "publish", arguments: { siteId: s.siteId } }), + ); + // Not a step-up: the installation role cannot give site:publish, so no challenge (§1). + expect(raw.status).not.toBe(403); + const agent = await mcpClient(s.app, access); + const publish = await agent.callTool({ name: "publish", arguments: { siteId: s.siteId } }); + expect(publish.isError).toBe(true); + }); + + it("keeps other members' connections invisible without agents:manage", async () => { + const s = await setupServer(backend); + const editor = await s.join("editor"); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const [ownerGrant] = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + expect((await s.call("GET", "/api/agent-grants", { cookie: editor.cookie })).body).toEqual( + [], + ); + const steal = await s.call("DELETE", `/api/agent-grants/${ownerGrant?.id}`, { + cookie: editor.cookie, + headers: { origin: s.origin }, + }); + expect(steal.status).toBe(404); + const all = await s.call("GET", "/api/agent-grants?all=1", { cookie: s.owner.cookie }); + expect((all.body as unknown as Json[])[0]?.owner).toMatchObject({ userId: s.owner.userId }); + }); + }); +} diff --git a/packages/api/src/testing-oauth-harness.ts b/packages/api/src/testing-oauth-harness.ts new file mode 100644 index 00000000..aa029f5e --- /dev/null +++ b/packages/api/src/testing-oauth-harness.ts @@ -0,0 +1,266 @@ +/** + * Test harness for RFC-0044 Phase 2: a deployment that runs the authorization server, a + * pre-registered public client (Phase 2 has no registration endpoint; `OauthServer.createClient` + * is server-side only), and the browser and client halves of the code flow with PKCE, driven + * with `app.request` and a signed-in session cookie. Used by `testing-mcp-oauth-server.ts`. + */ +import { AuditLog } from "@cloudhal/agent-runtime"; +import { seedStarterSite } from "@cloudhal/core-ts"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { expect } from "vitest"; +import { createApi } from "./app.ts"; +import { createAuth } from "./auth.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +export type Json = Record; +/** Loopback: the plugin accepts a plain-HTTP resource only there (RFC-0044 §10). */ +export const ORIGIN = "http://localhost:8787"; +export const ISSUER = `${ORIGIN}/api/auth`; +export const RESOURCE = `${ORIGIN}/mcp`; +/** A native client's loopback callback (RFC 8252 §7.3). */ +export const REDIRECT = "http://127.0.0.1:33418/callback"; +const SECRET = "mcp-oauth-server-secret-0123456789abcdefgh"; +const PASSWORD = "correct horse battery staple"; + +export interface DeploymentOptions { + /** Better Auth's base URL (the issuer is its origin plus `/api/auth`). */ + readonly baseURL?: string; + /** The `/mcp` resource tokens are bound to. */ + readonly resource?: string; +} + +export type Deployment = Awaited>; + +function base64url(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +export async function pkce(): Promise<{ verifier: string; challenge: string }> { + const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return { verifier, challenge: base64url(new Uint8Array(digest)) }; +} + +export const cookieOf = (res: Response) => + (res.headers.getSetCookie?.() ?? []) + .map((c) => c.split(";")[0] as string) + .filter((c) => c.startsWith("better-auth.session_token=")) + .join("; "); + +/** A tool call as a 2026-07-28 client sends it. */ +export function mcpCall(token: string, method: string, params: Json = {}): RequestInit { + return { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-protocol-version": "2026-07-28", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }; +} + +/** An MCP client on `app`'s `/mcp` with the access token (fails to connect on a 401). */ +export async function mcpClient( + app: { fetch: (request: Request) => Response | Promise }, + accessToken: string, +): Promise { + const client = new Client( + { name: "oauth-agent", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: "auto" } }, + ); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${ORIGIN}/mcp`), { + requestInit: { headers: { authorization: `Bearer ${accessToken}` } }, + fetch: async (input, init) => app.fetch(new Request(input, init as RequestInit)), + }), + ); + return client; +} + +/** A deployment over `backend`'s database (a second one may share it: same keys, other issuer). */ +export async function deploy( + b: Awaited>, + options: DeploymentOptions = {}, +) { + const baseURL = options.baseURL ?? ORIGIN; + const auth = createAuth({ + database: b.database, + sql: b.sql, + baseURL, + secret: SECRET, + signUp: "first-user", + mcpOauth: { resource: options.resource ?? `${new URL(baseURL).origin}/mcp` }, + }); + const server = auth.oauth; + if (!server) throw new Error("the authorization server did not start"); + const audit = new AuditLog(b.sql); + const app = createApi({ + store: b.store, + resolvePrincipal: auth.resolvePrincipal, + authHandler: auth.handler, + agentKeys: auth.agentKeys, + staff: auth.staff, + signUp: auth.signUp, + audit, + oauthServer: server, + mcp: { + stateKey: "mcp-oauth-state-key-0123456789abcdefghij", + audit, + oauth: { issuer: server.issuer, resource: server.resource }, + }, + }); + const origin = new URL(baseURL).origin; + const call = async ( + method: string, + path: string, + init: { cookie?: string; body?: unknown; headers?: Record } = {}, + ) => { + const res = await app.request(`${origin}${path}`, { + method, + headers: { + ...(init.body !== undefined ? { "content-type": "application/json", origin } : {}), + ...(init.cookie ? { cookie: init.cookie } : {}), + ...init.headers, + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + const text = await res.text(); + let body: Json = {}; + try { + body = text ? (JSON.parse(text) as Json) : {}; + } catch { + body = { text }; + } + return { res, status: res.status, body }; + }; + return { auth, server, app, audit, call, origin, sql: b.sql, store: b.store }; +} + +/** A fresh database with the Owner signed up, a site and the test client. */ +export async function setupServer(backend: RolesBackend) { + const b = await backend.create(); + const site = await seedStarterSite(b.store, { name: "Blog", slug: "blog" }); + const d = await deploy(b); + const signUp = await d.call("POST", "/api/auth/sign-up/email", { + body: { name: "Olive Owner", email: "owner@example.com", password: PASSWORD }, + }); + expect(signUp.status, JSON.stringify(signUp.body)).toBe(200); + const owner = { cookie: cookieOf(signUp.res), userId: (signUp.body.user as Json).id as string }; + const { clientId } = await d.server.createClient({ + headers: new Headers({ cookie: owner.cookie }), + name: "Test Agent", + redirectUris: [REDIRECT], + }); + /** A member who joined through an invite (RFC-0005 §7), signed in. */ + const join = async (role: string, extra: Json = {}) => { + const email = `${role}-${Math.random().toString(36).slice(2, 8)}@example.com`; + const inv = await d.call("POST", "/api/invites", { + cookie: owner.cookie, + body: { email, role, ...extra }, + }); + expect(inv.status, JSON.stringify(inv.body)).toBe(201); + const token = (inv.body.url as string).split("#")[1] as string; + const accepted = await d.call("POST", "/api/invites/accept", { + body: { token, name: role.toUpperCase(), password: PASSWORD }, + }); + expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); + return { + cookie: cookieOf(accepted.res), + userId: (accepted.body.user as Json).id as string, + }; + }; + return { ...d, backend: b, siteId: site.id, owner, clientId, join }; +} + +export type Server = Awaited>; + +export interface AuthorizeOptions { + readonly cookie: string; + readonly scope: string; + readonly clientId?: string; + readonly challengeMethod?: string | null; + readonly resource?: string; +} + +/** `GET /oauth2/authorize` as the member's browser: where it is sent next. */ +export async function authorize(s: Server, o: AuthorizeOptions) { + const { verifier, challenge } = await pkce(); + const params = new URLSearchParams({ + response_type: "code", + client_id: o.clientId ?? s.clientId, + redirect_uri: REDIRECT, + scope: o.scope, + state: "st4te", + resource: o.resource ?? RESOURCE, + }); + if (o.challengeMethod !== null) { + params.set("code_challenge", challenge); + params.set("code_challenge_method", o.challengeMethod ?? "S256"); + } + const res = await s.app.request(`${ISSUER}/oauth2/authorize?${params}`, { + headers: { cookie: o.cookie }, + }); + const location = res.headers.get("location") ?? ""; + return { res, location: location ? new URL(location, ORIGIN) : undefined, verifier }; +} + +/** The consent screen's request parameter: the signed query the provider sent the browser with. */ +export const consentRequest = (location: URL) => location.search.slice(1); + +/** Approve (or deny) on the consent screen; the URL the browser goes to next. */ +export async function consent( + s: Server, + cookie: string, + request: string, + body: { accept?: boolean; scopes: readonly string[]; mode?: string }, +) { + return s.call("POST", "/api/agent-grants/consent", { + cookie, + body: { request, accept: body.accept ?? true, scopes: body.scopes, mode: body.mode ?? "edits" }, + }); +} + +/** `POST /oauth2/token` as the client (form-encoded, `resource` named; RFC 8707). */ +export async function token(s: Server, form: Record) { + const res = await s.app.request(`${ISSUER}/oauth2/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, + body: new URLSearchParams({ client_id: s.clientId, resource: RESOURCE, ...form }), + }); + return { status: res.status, body: (await res.json().catch(() => ({}))) as Json }; +} + +/** The whole flow: authorize, consent with every requested scope, exchange the code. */ +export async function connect( + s: Server, + o: { cookie: string; scope: string; mode?: string; keep?: readonly string[] }, +) { + const start = await authorize(s, { cookie: o.cookie, scope: o.scope }); + expect(start.location?.pathname, start.location?.href).toBe("/oauth/consent"); + const request = consentRequest(start.location as URL); + const approved = await consent(s, o.cookie, request, { + scopes: o.keep ?? o.scope.split(" "), + ...(o.mode ? { mode: o.mode } : {}), + }); + expect(approved.status, JSON.stringify(approved.body)).toBe(200); + const back = new URL(approved.body.redirect as string); + expect(back.searchParams.get("iss")).toBe(ISSUER); + expect(back.searchParams.get("state")).toBe("st4te"); + const code = back.searchParams.get("code") ?? ""; + const tokens = await token(s, { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + code_verifier: start.verifier, + }); + expect(tokens.status, JSON.stringify(tokens.body)).toBe(200); + return { + access: tokens.body.access_token as string, + refresh: tokens.body.refresh_token as string | undefined, + body: tokens.body, + }; +} diff --git a/packages/api/test/better-auth-pins.test.ts b/packages/api/test/better-auth-pins.test.ts index 8e1a856d..f2378bab 100644 --- a/packages/api/test/better-auth-pins.test.ts +++ b/packages/api/test/better-auth-pins.test.ts @@ -44,4 +44,26 @@ describe("better-auth family pins", () => { expect(versions[name], name).toBe(ba.dependencies[name]); } }); + + // RFC-0044 §7: the OAuth plugins peer-depend on exactly this better-auth (`^1.7.18`), and a + // second `jose` in the tree resolves a second `@better-auth/core`, whose types no longer match. + it("keeps the plugins on better-auth's version and jose on the one better-auth resolves", () => { + const versions = catalog(); + for (const name of [ + "@better-auth/api-key", + "@better-auth/kysely-adapter", + "@better-auth/mcp", + "@better-auth/oauth-provider", + ]) + expect(versions[name], name).toBe(versions["better-auth"]); + let dir = path.dirname(require.resolve("better-auth")); + while (path.basename(dir) !== "better-auth") dir = path.dirname(dir); + const fromBetterAuth = createRequire(path.join(dir, "package.json")); + let jose = path.dirname(fromBetterAuth.resolve("jose")); + while (path.basename(jose) !== "jose") jose = path.dirname(jose); + const resolved = JSON.parse(readFileSync(path.join(jose, "package.json"), "utf8")) as { + version: string; + }; + expect(versions.jose).toBe(resolved.version); + }); }); diff --git a/packages/api/test/mcp-oauth-server.test.ts b/packages/api/test/mcp-oauth-server.test.ts new file mode 100644 index 00000000..d85e6f56 --- /dev/null +++ b/packages/api/test/mcp-oauth-server.test.ts @@ -0,0 +1,34 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import Database from "better-sqlite3"; +import { describeMcpOauthServer } from "../src/testing-mcp-oauth-server.ts"; + +describeMcpOauthServer("better-sqlite3", { + async create() { + const database = new Database(":memory:"); + const sql = new BetterSqlite3Driver(database); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await store.init(); + return { store, sql, blobs, database }; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describeMcpOauthServer("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await reset(sql, () => store.init()); + return { store, sql, blobs, database: d1.db }; + }, + async dispose() { + await d1?.dispose(); + }, +}); diff --git a/packages/api/test/route-scopes.test.ts b/packages/api/test/route-scopes.test.ts index cf1895cb..34716327 100644 --- a/packages/api/test/route-scopes.test.ts +++ b/packages/api/test/route-scopes.test.ts @@ -26,6 +26,7 @@ async function fullApp() { sql, baseURL: "http://localhost", secret: "route-scopes-secret-0123456789abcdefgh", + mcpOauth: { resource: "http://localhost/mcp" }, }); // Every optional surface mounted, so every route is enumerated. return createApi({ @@ -34,6 +35,7 @@ async function fullApp() { authHandler: auth.handler, agentKeys: auth.agentKeys, staff: auth.staff, + oauthServer: auth.oauth, mcp: { stateKey: "route-scopes-state-key-0123456789abcdefg", oauth: { issuer: "http://localhost/api/auth", resource: "http://localhost/mcp" }, diff --git a/packages/api/test/studio-endpoints.test.ts b/packages/api/test/studio-endpoints.test.ts index 3680df20..94b1f123 100644 --- a/packages/api/test/studio-endpoints.test.ts +++ b/packages/api/test/studio-endpoints.test.ts @@ -534,6 +534,7 @@ describe("instance and skills", () => { features: { agentKeys: false, mcp: true, + mcpOauth: false, activity: false, previewLinks: false, deployTransfer: false, diff --git a/packages/cli/src/commands/studio.ts b/packages/cli/src/commands/studio.ts index f9bb4d96..dc07a94f 100644 --- a/packages/cli/src/commands/studio.ts +++ b/packages/cli/src/commands/studio.ts @@ -2,6 +2,7 @@ import { access } from "node:fs/promises"; import path from "node:path"; import { ApprovalStore, AuditLog, loadSkills } from "@cloudhal/agent-runtime"; import { AiStore, type CloudhalApi, createApi, restTransport } from "@cloudhal/api"; +import { mcpOauthFor, type OauthServer } from "@cloudhal/api/auth"; import { CLIENT_ADDRESS_HEADER, serveNode } from "@cloudhal/api/node"; import { openLocalStore } from "@cloudhal/core-ts/local"; import { nodeTransport } from "@cloudhal/core-ts/media-node"; @@ -36,7 +37,7 @@ import { export interface StudioOptions extends StudioServerFlags, StudioInitFlags { readonly projectDir: string; - /** Progress lines (the first-run scaffold); default: nothing. */ + /** Progress lines (the first-run scaffold, why OAuth is off); default: nothing. */ readonly log?: (line: string) => void; /** Directory of a built Studio (`apps/studio/dist`). Defaults to the monorepo build when present. */ readonly studioDist?: string; @@ -168,6 +169,8 @@ export async function studio(options: StudioOptions): Promise { }, }; let firstOwner: SetupToken | undefined; + // RFC-0044 §10: the issuer is the public base URL; without one, OAuth stays off. + const oauth = mcpOauthFor(server.baseUrl, env.CLOUDHAL_MCP_OAUTH); if (setup) { const { createAuthFor } = await import("@cloudhal/api/auth/node"); const auth = await createAuthFor({ @@ -179,6 +182,7 @@ export async function studio(options: StudioOptions): Promise { trustRequestOrigin: origins.trustRequestOrigin, setupToken: setup.token, originCheck: options.authOriginCheck, + ...oauthOption(oauth), }); deps = { ...deps, @@ -188,6 +192,7 @@ export async function studio(options: StudioOptions): Promise { agentKeys: auth.agentKeys, staff: auth.staff, signUp: auth.signUp, + ...withOauthServer(auth.oauth, deps.mcp), }; // RFC-0005 §7, #165: until somebody has an account, the first sign-up needs the token. if (!(await hasAccounts(local.sql))) firstOwner = setup; @@ -202,6 +207,7 @@ export async function studio(options: StudioOptions): Promise { }); const url = studioUrl(server, listening.port); const warning = noBaseUrlWarning(server); + logOauthOff(options.log, setup ? oauth : undefined); return { url, port: listening.port, @@ -218,6 +224,32 @@ export async function studio(options: StudioOptions): Promise { }; } +/** Logs why remote MCP clients cannot connect by URL (RFC-0044 §10); nothing when they can. */ +function logOauthOff( + log: ((line: string) => void) | undefined, + oauth: ReturnType | undefined, +): void { + if (oauth && "off" in oauth) + log?.(`OAuth for remote MCP clients is off: ${oauth.off}. Agent keys still work.`); +} + +/** `createAuthFor`'s `mcpOauth` when the authorization server runs. */ +function oauthOption(oauth: ReturnType) { + return "mcpOauth" in oauth ? { mcpOauth: oauth.mcpOauth } : {}; +} + +/** RFC-0044 Phase 2: remote MCP clients connect by URL; discovery names this issuer. */ +function withOauthServer( + server: OauthServer | undefined, + mcp: Parameters[0]["mcp"], +): Partial[0]> { + if (!server || !mcp) return {}; + return { + oauthServer: server, + mcp: { ...mcp, oauth: { issuer: server.issuer, resource: server.resource } }, + }; +} + /** A built Studio next to the API: its assets, and `index.html` for every path that is not the API's. */ async function serveSpa(app: CloudhalApi, studioDist: string): Promise { const { serveStatic } = await import("@hono/node-server/serve-static"); diff --git a/packages/cli/test/cloudflare-deploy.test.ts b/packages/cli/test/cloudflare-deploy.test.ts index 5f46e62c..86546479 100644 --- a/packages/cli/test/cloudflare-deploy.test.ts +++ b/packages/cli/test/cloudflare-deploy.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { exportSite } from "@cloudhal/core-ts"; import { openLocalStore } from "@cloudhal/core-ts/local"; +import { D1Driver } from "@cloudhal/storage/d1"; import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestHarness } from "wrangler"; @@ -386,4 +387,118 @@ describe("Cloudflare deployment (admin + site Workers)", () => { }); expect(replay.status).toBe(410); }); + it("connects a remote MCP client with OAuth on workerd: Ed25519 JWT, D1, consent (RFC-0044 Phase 2)", async () => { + const browser = { origin: "https://admin.example" }; + const signIn = await adminFetch("/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json", ...browser }, + body: JSON.stringify({ + email: "owner@example.com", + password: "correct horse battery staple", + }), + }); + expect(signIn.status, await signIn.clone().text()).toBe(200); + const cookie = (signIn.headers.get("set-cookie") ?? "").split(";")[0] as string; + // Discovery: the 401 names the metadata, which names this deployment's issuer. + const first = await adminFetch("/mcp", { method: "POST" }); + expect(first.status).toBe(401); + expect(first.headers.get("www-authenticate")).toContain( + 'resource_metadata="https://admin.example/.well-known/oauth-protected-resource/mcp"', + ); + const meta = (await ( + await adminFetch("/.well-known/oauth-authorization-server/api/auth") + ).json()) as Record; + expect(meta.issuer).toBe("https://admin.example/api/auth"); + // A pre-registered public client (registration is Phase 3), written as the provider stores it. + const env = (await harness.getWorker(W.admin).getEnv()) as { DB: D1Database }; + const db = new D1Driver(env.DB); + const now = new Date().toISOString(); + const redirect = "http://127.0.0.1:41000/callback"; + await db.batch([ + { + sql: `INSERT INTO "oauthClient" ("id", "clientId", "disabled", "scopes", "createdAt", "updatedAt", "name", "redirectUris", "tokenEndpointAuthMethod", "applicationType", "grantTypes", "responseTypes") VALUES ('wclient', 'workerd-client', 0, '["site:read","content:read","offline_access"]', ?, ?, 'Workerd Agent', ?, 'none', 'native', '["authorization_code","refresh_token"]', '["code"]')`, + params: [now, now, JSON.stringify([redirect])], + }, + { + sql: `INSERT INTO "oauthClientResource" ("id", "clientId", "resourceId", "createdAt") VALUES ('wlink', 'workerd-client', 'https://admin.example/mcp', ?)`, + params: [now], + }, + ]); + const verifier = "workerd-verifier-0123456789-abcdefghijklmnopqrstuvwxyz"; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + const query = new URLSearchParams({ + response_type: "code", + client_id: "workerd-client", + redirect_uri: redirect, + scope: "site:read content:read", + state: "w", + code_challenge: challenge, + code_challenge_method: "S256", + resource: "https://admin.example/mcp", + }); + const start = await adminFetch(`/api/auth/oauth2/authorize?${query}`, { + headers: { cookie }, + redirect: "manual", + }); + // A navigation gets a 302; a fetch without `Accept: text/html` the same URL as JSON. + const next = start.headers.get("location") ?? ((await start.json()) as { url: string }).url; + const consentPage = new URL(next, "https://admin.example"); + expect(consentPage.pathname).toBe("/oauth/consent"); + const approved = await adminFetch("/api/agent-grants/consent", { + method: "POST", + headers: { "content-type": "application/json", cookie, ...browser }, + body: JSON.stringify({ + request: consentPage.search.slice(1), + accept: true, + scopes: ["site:read", "content:read"], + mode: "plan", + }), + }); + expect(approved.status, await approved.clone().text()).toBe(200); + const code = new URL( + ((await approved.json()) as { redirect: string }).redirect, + ).searchParams.get("code"); + const tokenRes = await adminFetch("/api/auth/oauth2/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code ?? "", + redirect_uri: redirect, + client_id: "workerd-client", + code_verifier: verifier, + resource: "https://admin.example/mcp", + }).toString(), + }); + expect(tokenRes.status, await tokenRes.clone().text()).toBe(200); + const { access_token: access } = (await tokenRes.json()) as { access_token: string }; + const header = JSON.parse( + atob((access.split(".")[0] as string).replaceAll("-", "+").replaceAll("_", "/")), + ) as Record; + expect(header).toMatchObject({ alg: "EdDSA", typ: "at+jwt" }); + const agent = new Client( + { name: "oauth-agent", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: "auto" } }, + ); + await agent.connect( + new StreamableHTTPClientTransport(new URL("https://admin.example/mcp"), { + requestInit: { headers: { authorization: `Bearer ${access}` } }, + fetch: async (input, init) => { + const req = new Request(input, init as RequestInit); + return adminFetch(new URL(req.url).pathname, { + method: req.method, + headers: Object.fromEntries(req.headers), + ...(req.method === "GET" || req.method === "HEAD" ? {} : { body: await req.text() }), + }); + }, + }), + ); + const mode = await agent.callTool({ name: "get_mode", arguments: {} }); + expect(JSON.stringify(mode.content)).toContain("plan"); + await agent.close(); + }); }); diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts index 7e9637cb..902b8c43 100644 --- a/packages/cloudflare/src/admin-worker.ts +++ b/packages/cloudflare/src/admin-worker.ts @@ -47,7 +47,7 @@ import { AnalyticsService } from "@cloudhal/analytics/service"; import { SqlQueryBudget } from "@cloudhal/analytics/sql"; import { DATASET_RE, WaeReader } from "@cloudhal/analytics/wae"; import { type AiBinding, AiStore, bindingTransport, createApi } from "@cloudhal/api"; -import { type CloudhalAuth, createAuth } from "@cloudhal/api/auth"; +import { type CloudhalAuth, createAuth, mcpOauthFor } from "@cloudhal/api/auth"; import { PURGE_LIMITS } from "@cloudhal/comments"; import { SqlCommentStore } from "@cloudhal/comments/sql"; import { type AiSearchEnv, SiteGraphStore, searchProviderFromEnv } from "@cloudhal/core-ts"; @@ -104,6 +104,36 @@ export interface AdminEnv extends CommentsEnv, SchedulerEnv, MailEnv, BuildRooms BROWSER?: BrowserBinding; /** `off` keeps `render_page` unavailable even with a `BROWSER` binding. */ CLOUDHAL_RENDER?: string; + /** `off` turns the OAuth authorization server for remote MCP clients off (RFC-0044 §10). */ + CLOUDHAL_MCP_OAUTH?: string; +} + +/** + * Better Auth for one request: request-scoped state (background tasks, plugin promises) and no + * I/O at construction. RFC-0044 §10: the OAuth issuer is the origin the request came to + * (`CLOUDHAL_MCP_OAUTH=off` turns the authorization server off). + */ +function adminAuth( + env: AdminEnv, + sql: D1Driver, + url: URL, + run: { readonly waitUntil: (p: Promise) => void; readonly log: (line: string) => void }, +): CloudhalAuth { + const oauth = mcpOauthFor(url.origin, env.CLOUDHAL_MCP_OAUTH); + return createAuth({ + database: env.DB, + sql, + baseURL: url.origin, + secret: required(env, "BETTER_AUTH_SECRET"), + signUp: + env.CLOUDHAL_SIGNUP === "open" || env.CLOUDHAL_SIGNUP === "closed" + ? env.CLOUDHAL_SIGNUP + : "first-user", + setupToken: env.CLOUDHAL_SETUP_TOKEN, + workers: { waitUntil: run.waitUntil }, + log: (level, message) => run.log(`auth ${level}: ${message}`), + ...("mcpOauth" in oauth ? { mcpOauth: oauth.mcpOauth } : {}), + }); } /** Default daily budget of uncached live queries: 80 % of WAE Free's 10,000 (§7.7, A2). */ @@ -426,20 +456,7 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { }); if (due) ctx.waitUntil(due); - // Per request: Better Auth keeps request-scoped state (background tasks, plugin promises), - // and its construction does no I/O. - const auth: CloudhalAuth = createAuth({ - database: env.DB, - sql, - baseURL: url.origin, - secret: required(env, "BETTER_AUTH_SECRET"), - signUp: - env.CLOUDHAL_SIGNUP === "open" || env.CLOUDHAL_SIGNUP === "closed" - ? env.CLOUDHAL_SIGNUP - : "first-user", - setupToken: env.CLOUDHAL_SETUP_TOKEN, - workers: { waitUntil: (p) => ctx.waitUntil(p) }, - }); + const auth = adminAuth(env, sql, url, { waitUntil: (p) => ctx.waitUntil(p), log }); const audit = new AuditLog(sql); const approvals = new ApprovalStore(sql); const mail = adminMail(env); @@ -452,6 +469,7 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { staff: auth.staff, signUp: auth.signUp, audit, + oauthServer: auth.oauth, // RFC-0011: emailed invites and reset links when `cloudhal deploy` bound a sender. ...(mail ? { mail } : {}), defer: (task) => ctx.waitUntil(task), @@ -465,6 +483,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { approvals, // Remote clients must speak the stateless 2026-07-28 protocol or be served statelessly. legacy: "stateless", + // RFC-0044: discovery names this deployment's authorization server. + oauth: auth.oauth && { issuer: auth.oauth.issuer, resource: auth.oauth.resource }, // fetch_asset_url never reaches this Worker's own host (Studio, API, purge); each site's // domains are refused too. Workers cannot pin DNS: see `workersTransport` (RFC-0004 §5.7). media: { blockedHosts: [url.hostname] }, diff --git a/packages/storage/migrations/0031_oauth.ts b/packages/storage/migrations/0031_oauth.ts new file mode 100644 index 00000000..b1e0fea1 --- /dev/null +++ b/packages/storage/migrations/0031_oauth.ts @@ -0,0 +1,60 @@ +import type { Migration } from "../src/migrate.ts"; + +/** + * RFC-0044 §7 (Phase 2): the remote MCP authorization server's tables. + * + * 1. Better Auth's `jwt()` and `mcp()` / `@better-auth/oauth-provider` tables (`jwks`, + * `oauthClient`, `oauthResource`, `oauthClientResource`, `oauthRefreshToken`, + * `oauthAccessToken`, `oauthConsent`, `oauthClientAssertion`) and their indexes. Generated by + * better-auth 1.7.18 (`@better-auth/mcp` 1.7.18, `@better-auth/oauth-provider` 1.7.18) + * `getMigrations(auth.options).compileMigrations()` on a database at 0030, with the instance + * `createInstance` builds when `AuthOptions.mcpOauth` is set; never edit by hand. Nothing in the + * existing auth tables changes. + * 2. Cloudhal's `agent_grants`: one row per member and OAuth client, written on Studio's consent + * screen. It carries what a token does not: the scopes after narrowing by the member's role, + * the agent mode, an elevation and the revocation, read on every `/mcp` call (§5). + * + * Plain SQL, one atomic batch; additive, so a downgrade ignores the tables (§7 rollback). + */ +export const migration0031Oauth: Migration = { + name: "0031_oauth", + sql: ` +create table "jwks" ("id" text not null primary key, "publicKey" text not null, "privateKey" text not null, "createdAt" date not null, "expiresAt" date, "alg" text, "crv" text); +create table "oauthClient" ("id" text not null primary key, "clientId" text not null unique, "clientSecret" text, "clientDiscoveryId" text, "disabled" integer, "skipConsent" integer, "enableEndSession" integer, "subjectType" text, "scopes" text, "clientCredentialsScopes" text, "userId" text references "user" ("id") on delete cascade, "createdAt" date, "updatedAt" date, "name" text, "uri" text, "icon" text, "contacts" text, "tos" text, "policy" text, "softwareId" text, "softwareVersion" text, "softwareStatement" text, "redirectUris" text not null, "postLogoutRedirectUris" text, "backchannelLogoutUri" text, "backchannelLogoutSessionRequired" integer, "tokenEndpointAuthMethod" text, "applicationType" text, "jwks" text, "jwksUri" text, "grantTypes" text, "responseTypes" text, "requirePKCE" integer, "dpopBoundAccessTokens" integer, "referenceId" text, "metadata" text); +create table "oauthResource" ("id" text not null primary key, "identifier" text not null unique, "name" text not null, "accessTokenTtl" integer, "refreshTokenTtl" integer, "signingAlgorithm" text, "signingKeyId" text, "allowedScopes" text, "customClaims" text, "dpopBoundAccessTokensRequired" integer, "disabled" integer, "createdAt" date, "updatedAt" date, "policyVersion" integer, "metadata" text); +create table "oauthClientResource" ("id" text not null primary key, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "resourceId" text not null references "oauthResource" ("identifier") on delete cascade, "metadata" text, "createdAt" date); +create table "oauthRefreshToken" ("id" text not null primary key, "token" text not null unique, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "sessionId" text references "session" ("id") on delete set null, "userId" text not null references "user" ("id") on delete cascade, "referenceId" text, "authorizationCodeId" text, "resources" text, "requestedUserInfoClaims" text, "expiresAt" date not null, "createdAt" date not null, "revoked" date, "rotatedAt" date, "rotationReplayResponse" text, "rotationReplayExpiresAt" date, "authTime" date, "confirmation" text, "scopes" text not null); +create table "oauthAccessToken" ("id" text not null primary key, "token" text not null unique, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "sessionId" text references "session" ("id") on delete set null, "userId" text references "user" ("id") on delete cascade, "referenceId" text, "authorizationCodeId" text, "resources" text, "requestedUserInfoClaims" text, "refreshId" text references "oauthRefreshToken" ("id") on delete cascade, "expiresAt" date not null, "createdAt" date not null, "revoked" date, "confirmation" text, "scopes" text not null); +create table "oauthConsent" ("id" text not null primary key, "clientId" text not null references "oauthClient" ("clientId") on delete cascade, "userId" text references "user" ("id") on delete cascade, "referenceId" text, "resources" text, "requestedUserInfoClaims" text, "scopes" text not null, "createdAt" date not null, "updatedAt" date not null); +create table "oauthClientAssertion" ("id" text not null primary key, "expiresAt" date not null); +create index "oauthClient_userId_idx" on "oauthClient" ("userId"); +create index "oauthClientResource_clientId_idx" on "oauthClientResource" ("clientId"); +create index "oauthClientResource_resourceId_idx" on "oauthClientResource" ("resourceId"); +create index "oauthRefreshToken_clientId_idx" on "oauthRefreshToken" ("clientId"); +create index "oauthRefreshToken_sessionId_idx" on "oauthRefreshToken" ("sessionId"); +create index "oauthRefreshToken_userId_idx" on "oauthRefreshToken" ("userId"); +create index "oauthRefreshToken_authorizationCodeId_idx" on "oauthRefreshToken" ("authorizationCodeId"); +create index "oauthAccessToken_clientId_idx" on "oauthAccessToken" ("clientId"); +create index "oauthAccessToken_sessionId_idx" on "oauthAccessToken" ("sessionId"); +create index "oauthAccessToken_userId_idx" on "oauthAccessToken" ("userId"); +create index "oauthAccessToken_authorizationCodeId_idx" on "oauthAccessToken" ("authorizationCodeId"); +create index "oauthAccessToken_refreshId_idx" on "oauthAccessToken" ("refreshId"); +create index "oauthConsent_clientId_idx" on "oauthConsent" ("clientId"); +create index "oauthConsent_userId_idx" on "oauthConsent" ("userId"); +create unique index "oauthClientResource_clientId_resourceId_uidx" on "oauthClientResource" ("clientId", "resourceId"); +CREATE TABLE agent_grants ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + client_id TEXT NOT NULL, + client_name TEXT NOT NULL, + scopes TEXT NOT NULL, + mode TEXT NOT NULL, + elevation TEXT, + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT, + UNIQUE (user_id, client_id) +); +CREATE INDEX agent_grants_user ON agent_grants (user_id); +`, +}; diff --git a/packages/storage/migrations/index.ts b/packages/storage/migrations/index.ts index 765dc640..20aab2eb 100644 --- a/packages/storage/migrations/index.ts +++ b/packages/storage/migrations/index.ts @@ -29,6 +29,7 @@ import { migration0027MultiSite } from "./0027_multi_site.ts"; import { migration0028SiteMembers } from "./0028_site_members.ts"; import { migration0029EntryListValues } from "./0029_entry_list_values.ts"; import { migration0030RateLimits } from "./0030_rate_limits.ts"; +import { migration0031Oauth } from "./0031_oauth.ts"; /** All Cloudhal storage migrations, in order. */ export const migrations: readonly Migration[] = [ @@ -62,4 +63,5 @@ export const migrations: readonly Migration[] = [ migration0028SiteMembers, migration0029EntryListValues, migration0030RateLimits, + migration0031Oauth, ]; diff --git a/packages/storage/test/migrate.test.ts b/packages/storage/test/migrate.test.ts index 1c029ba8..2f31dec7 100644 --- a/packages/storage/test/migrate.test.ts +++ b/packages/storage/test/migrate.test.ts @@ -48,6 +48,7 @@ describe("migrate", () => { "0028_site_members", "0029_entry_list_values", "0030_rate_limits", + "0031_oauth", ]); expect((await migrate(driver, migrations)).applied).toEqual([]); expect(await appliedMigrations(driver)).toEqual([ @@ -81,6 +82,7 @@ describe("migrate", () => { "0028_site_members", "0029_entry_list_values", "0030_rate_limits", + "0031_oauth", ]); const tables = await driver.all<{ name: string }>( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", @@ -88,6 +90,7 @@ describe("migrate", () => { expect(tables.map((t) => t.name)).toEqual([ "_migrations", "account", + "agent_grants", "agent_owner", "ai_settings", "ai_usage", @@ -117,10 +120,18 @@ describe("migrate", () => { "entry_list_state", "entry_list_values", "invitation", + "jwks", "media_blobs", "media_uploads", "member", "nodes", + "oauthAccessToken", + "oauthClient", + "oauthClientAssertion", + "oauthClientResource", + "oauthConsent", + "oauthRefreshToken", + "oauthResource", "op_guards", "organization", "password_reset", @@ -194,6 +205,7 @@ describe("migrate", () => { "0028_site_members", "0029_entry_list_values", "0030_rate_limits", + "0031_oauth", ]); expect(await appliedMigrations(driver)).toEqual([ "0001_init", @@ -226,6 +238,7 @@ describe("migrate", () => { "0028_site_members", "0029_entry_list_values", "0030_rate_limits", + "0031_oauth", ]); await h.dispose(); }); @@ -265,6 +278,7 @@ describe("migrate", () => { "0028_site_members", "0029_entry_list_values", "0030_rate_limits", + "0031_oauth", ]); const ok = await driver.get("SELECT name FROM sqlite_master WHERE name = 'ok'"); expect(ok).toBeUndefined(); @@ -328,3 +342,44 @@ describe("0012_roles: existing users become members (RFC-0005 §11)", () => { await (h ? h.dispose() : driver.close()); }); }); + +describe("0031_oauth: the authorization server's tables (RFC-0044 §7)", () => { + const before = migrations.filter((m) => m.name < "0031_oauth"); + const oauth = migrations.filter((m) => m.name === "0031_oauth"); + for (const backend of ["better-sqlite3", "D1 (Miniflare)"] as const) + it(`applies on a database at 0030, and agent_grants is one row per member and client (${backend})`, async () => { + const h = backend === "D1 (Miniflare)" ? await createMiniflareD1() : undefined; + const driver = h ? new D1Driver(h.db) : await BetterSqlite3Driver.open(":memory:"); + await migrate(driver, before); + expect((await migrate(driver, oauth)).applied).toEqual(["0031_oauth"]); + const tables = await driver.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE 'oauth%' OR name IN ('jwks', 'agent_grants')) ORDER BY name", + ); + expect(tables.map((t) => t.name)).toEqual([ + "agent_grants", + "jwks", + "oauthAccessToken", + "oauthClient", + "oauthClientAssertion", + "oauthClientResource", + "oauthConsent", + "oauthRefreshToken", + "oauthResource", + ]); + const grant = (id: string, user: string, client: string) => ({ + sql: "INSERT INTO agent_grants (id, user_id, client_id, client_name, scopes, mode, created_at) VALUES (?, ?, ?, 'Claude', '[]', 'edits', 't')", + params: [id, user, client], + }); + await driver.batch([ + grant("g1", "u1", "c1"), + grant("g2", "u1", "c2"), + grant("g3", "u2", "c1"), + ]); + await expect(driver.batch([grant("g4", "u1", "c1")])).rejects.toThrow(); + const index = await driver.get<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'agent_grants_user'", + ); + expect(index?.name).toBe("agent_grants_user"); + await (h ? h.dispose() : driver.close()); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26316ec3..1357a242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,11 +171,17 @@ catalogs: specifier: 0.42.6 version: 0.42.6 '@better-auth/api-key': - specifier: 1.7.17 - version: 1.7.17 + specifier: 1.7.18 + version: 1.7.18 '@better-auth/core': - specifier: 1.7.17 - version: 1.7.17 + specifier: 1.7.18 + version: 1.7.18 + '@better-auth/mcp': + specifier: 1.7.18 + version: 1.7.18 + '@better-auth/oauth-provider': + specifier: 1.7.18 + version: 1.7.18 '@better-auth/utils': specifier: 0.5.1 version: 0.5.1 @@ -300,8 +306,8 @@ catalogs: specifier: ^7.4.7 version: 7.4.7 better-auth: - specifier: 1.7.17 - version: 1.7.17 + specifier: 1.7.18 + version: 1.7.18 better-call: specifier: 1.4.5 version: 1.4.5 @@ -317,6 +323,9 @@ catalogs: hono: specifier: ^4.13.13 version: 4.13.13 + jose: + specifier: 6.2.12 + version: 6.2.12 json-schema-to-typescript: specifier: 16.0.0 version: 16.0.0 @@ -474,7 +483,7 @@ importers: version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) better-auth: specifier: 'catalog:' - version: 1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) react: specifier: 'catalog:' version: 19.3.1 @@ -582,10 +591,16 @@ importers: dependencies: '@better-auth/api-key': specifier: 'catalog:' - version: 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(better-auth@1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)) + version: 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)) '@better-auth/core': specifier: 'catalog:' - version: 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + version: 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/mcp': + specifier: 'catalog:' + version: 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)) + '@better-auth/oauth-provider': + specifier: 'catalog:' + version: 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)) '@better-auth/utils': specifier: 'catalog:' version: 0.5.1 @@ -618,13 +633,16 @@ importers: version: link:../storage better-auth: specifier: 'catalog:' - version: 1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) better-call: specifier: 'catalog:' version: 1.4.5(zod@4.6.7) hono: specifier: 'catalog:' version: 4.13.13 + jose: + specifier: 'catalog:' + version: 6.2.12 devDependencies: '@cloudflare/workers-types': specifier: 'catalog:' @@ -1397,16 +1415,16 @@ packages: '@types/react': optional: true - '@better-auth/api-key@1.7.17': - resolution: {integrity: sha512-qsU+Z7oNu0ZOx5joJKuVNEL9JvrMu/+Hkivda/CPZ9Efj65DTWqWTdgNXABDJGiomL2VocjFF3VA4lOkBXY1+A==} + '@better-auth/api-key@1.7.18': + resolution: {integrity: sha512-LEB0GLTM+WJlRx046P7Hmt5CpA3E6Cqc/LDEYhUDa86Mpi43kAZrxfD3i/d/4z9rP8dtmmj44JWQyaylO/ArDw==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 - better-auth: ^1.7.17 + better-auth: ^1.7.18 better-call: 1.4.5 - '@better-auth/core@1.7.17': - resolution: {integrity: sha512-GB3OtOjNfeOKxKOQByXLdj3CNNDbZ65HdbfHSZhoLI6VzlURlb2VXMFnf05NE8fhDoytXY+zlbqTcwaWKTS7/g==} + '@better-auth/core@1.7.18': + resolution: {integrity: sha512-GPue1yR24fd+RkCiOpVtdBwtCqkgGHRMGxUL6A11LzUv0JsHA2hnIFEdoxorr9TWO2kJLvYOuUu0vjV9Wxd4AA==} peerDependencies: '@better-auth/utils': ^0.4.2 || ^0.5.0 '@better-fetch/fetch': 1.3.3 @@ -1419,46 +1437,62 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.7.17': - resolution: {integrity: sha512-A63iAB9tZLw/HU2bwf+FNwd3Hm5dzoNULOJckzPVMnddf0hO/gnjM94D/yVuu7w9cg8QTVAV7Zu3ehz1lKnX9A==} + '@better-auth/drizzle-adapter@1.7.18': + resolution: {integrity: sha512-l+J+WG11dQ9kzcAyAxgo5qh7bqGzsvN4uApR45limXe9gR02kijQi42xHJlWN8+TL6mmA69EC7W4MWbaHvmHGg==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.7.17': - resolution: {integrity: sha512-YPBdzMtwky0p3Jwbp6FmO7CnREOkxXJklwJsm2Hl6MN4qF+AY7T61Wq3IZ784BP7fmW7AaSPjkTe4+OGrFz1mQ==} + '@better-auth/kysely-adapter@1.7.18': + resolution: {integrity: sha512-hFpFu1DRsis6pNB1riUzwXsbKi/3RilhUeGw+bY3iHduf7zcT4UmBG7tyltWgmE16MF8X7sgMbS9bL7G4pC6dQ==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.7.17': - resolution: {integrity: sha512-nEFRisxbSy1i3nmP0mqR5kwtNpYWSOqcdT+0axcPHeGK7DIeN6lpc5bEoPIkxPcSTyDzv0K67jlt1A60wGUY4g==} + '@better-auth/mcp@1.7.18': + resolution: {integrity: sha512-72NkYPS72oOk78BPZEe7oTHndyayNNhq5neOBiEvolq3tZbwgL88XzQXoLh6V6ITzsc4Q7KiSMbkWO4hjbqPgg==} + peerDependencies: + '@better-auth/core': ^1.7.18 + better-auth: ^1.7.18 + better-call: 1.4.5 + + '@better-auth/memory-adapter@1.7.18': + resolution: {integrity: sha512-nEjQsThcsPOInoAq24vqjm/Rk6NrAkvl/K3Cpokp1mw0KM4IFNeT+EZV/IYX1MGK/QXNPA1q6N7f8pIxQHq1og==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 - '@better-auth/mongo-adapter@1.7.17': - resolution: {integrity: sha512-Pu6rutW9h3K+pUiiDCtedjdPgD/62cYScKCM04az8g7SGVDJVAWF73ly3cN2EflYnLs96yp68e4zAnr0XJDgsQ==} + '@better-auth/mongo-adapter@1.7.18': + resolution: {integrity: sha512-0Fdw+0KkPhaxN3B4+PMlQWNoxSYiyBmU+puF+oujV3l5/41TOJyogzE+BHeH2pZ6WymAd3d4mfpl3iCodS/XlQ==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.7.17': - resolution: {integrity: sha512-gxowKIc8X8fUDPikuN+kWtfcGjXcy6CY/dpxJecSBDCn61g+T0AJdQpUzu7KhkjfYyw/ADxk7xtAc00tR7BoFA==} + '@better-auth/oauth-provider@1.7.18': + resolution: {integrity: sha512-NIO57w5+Z0HRsPEXDkt5PxZ1FcVltY815tmoRzqgfwJ9HFAI1Bm3vfMj2MFFVJF6FIqtutJHEghwgaokG2dIRA==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 + '@better-auth/utils': ^0.4.2 || ^0.5.0 + '@better-fetch/fetch': 1.3.3 + better-auth: ^1.7.18 + better-call: 1.4.5 + + '@better-auth/prisma-adapter@1.7.18': + resolution: {integrity: sha512-NbGtY+WU5vV5M1wYTypNQRSJS45QafK5JkFkcWggshoSIAypI8/5sj5ilrO4T+vqfubNISi2Z6LPNIscVBg3Pg==} + peerDependencies: + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1468,10 +1502,10 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.7.17': - resolution: {integrity: sha512-VhBBYBBtSK6tZlEdc5MwV6L/8HHjuGIns9QmlhqxiCk2xA4ILAVXo7O9S1iltxqe+H8gUKh5jbpY9hriW88MeQ==} + '@better-auth/telemetry@1.7.18': + resolution: {integrity: sha512-mhZxFhzG28dQLdSBVXZp0v5P3gTCylu5q5U/XmV8UVucN449+9JLzktO41Z89pAV/mt4Q1mooNC7w0Vax9B4vA==} peerDependencies: - '@better-auth/core': ^1.7.17 + '@better-auth/core': ^1.7.18 '@better-auth/utils': ^0.4.2 || ^0.5.0 '@better-fetch/fetch': 1.3.3 @@ -3363,8 +3397,8 @@ packages: bcp-47@2.1.1: resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} - better-auth@1.7.17: - resolution: {integrity: sha512-jLPt758k4R3kzCE2ie5BV2FXiLtpbINyC6e/OkcZK0aQOAnZdE2oLZofK1cZg3oXVd2GCFo47gnMy7sgykfUhQ==} + better-auth@1.7.18: + resolution: {integrity: sha512-VmPc441MZhOKYaRHLgJTXl3T4H7tWZiiYJoMSTl0ZhsEKyAjGm1UdJfghZmFMpzNe+1NLCTUoTAARhi58vcd0g==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -5233,15 +5267,15 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@better-auth/api-key@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(better-auth@1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))': + '@better-auth/api-key@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 - better-auth: 1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + better-auth: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) better-call: 1.4.5(zod@4.6.7) zod: 4.6.7 - '@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': + '@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5)': dependencies: '@better-auth/utils': 0.5.1 '@better-fetch/fetch': 1.3.3 @@ -5252,36 +5286,57 @@ snapshots: nanostores: 1.5.5 zod: 4.6.7 - '@better-auth/drizzle-adapter@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': + '@better-auth/drizzle-adapter@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 - '@better-auth/kysely-adapter@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.6)': + '@better-auth/kysely-adapter@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.6)': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 optionalDependencies: kysely: 0.29.6 - '@better-auth/memory-adapter@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': + '@better-auth/mcp@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/oauth-provider': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7)) + better-auth: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + better-call: 1.4.5(zod@4.6.7) + jose: 6.2.12 + transitivePeerDependencies: + - '@better-auth/utils' + - '@better-fetch/fetch' + + '@better-auth/memory-adapter@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': + dependencies: + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 - '@better-auth/mongo-adapter@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': + '@better-auth/mongo-adapter@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 - '@better-auth/prisma-adapter@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': + '@better-auth/oauth-provider@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))))(better-call@1.4.5(zod@4.6.7))': + dependencies: + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/utils': 0.5.1 + '@better-fetch/fetch': 1.3.3 + better-auth: 1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + better-call: 1.4.5(zod@4.6.7) + jose: 6.2.12 + zod: 4.6.7 + + '@better-auth/prisma-adapter@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 - '@better-auth/telemetry@1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)': + '@better-auth/telemetry@1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)': dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) '@better-auth/utils': 0.5.1 '@better-fetch/fetch': 1.3.3 @@ -6961,15 +7016,15 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 - better-auth@1.7.17(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): + better-auth@1.7.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.1.0(@types/node@26.6.5)(happy-dom@21.0.4)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))): dependencies: - '@better-auth/core': 1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) - '@better-auth/drizzle-adapter': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) - '@better-auth/kysely-adapter': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.6) - '@better-auth/memory-adapter': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) - '@better-auth/mongo-adapter': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) - '@better-auth/prisma-adapter': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) - '@better-auth/telemetry': 1.7.17(@better-auth/core@1.7.17(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3) + '@better-auth/core': 1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5) + '@better-auth/drizzle-adapter': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) + '@better-auth/kysely-adapter': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.6) + '@better-auth/memory-adapter': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) + '@better-auth/mongo-adapter': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) + '@better-auth/prisma-adapter': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1) + '@better-auth/telemetry': 1.7.18(@better-auth/core@1.7.18(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.5(zod@4.6.7))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3) '@better-auth/utils': 0.5.1 '@better-fetch/fetch': 1.3.3 '@noble/ciphers': 2.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ff0016f..2c1636e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,10 +50,13 @@ catalog: "@lezer/highlight": 1.2.5 hono: ^4.13.13 "@hono/node-server": ^2.1.4 - better-auth: 1.7.17 - "@better-auth/kysely-adapter": 1.7.12 - "@better-auth/api-key": 1.7.17 - "@better-auth/core": 1.7.17 + better-auth: 1.7.18 + "@better-auth/kysely-adapter": 1.7.18 + "@better-auth/api-key": 1.7.18 + "@better-auth/core": 1.7.18 + "@better-auth/oauth-provider": 1.7.18 + "@better-auth/mcp": 1.7.18 + jose: 6.2.12 "@better-auth/utils": 0.5.1 better-call: 1.4.5 "@modelcontextprotocol/server": 2.3.1 diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index db95a8a3..3ca9e831 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -304,7 +304,7 @@ "params": 5 }, "packages/api/src/app.ts::createApi": { - "lines": 173 + "lines": 160 }, "packages/api/src/build-turn.ts::runBuildTurn": { "lines": 260 @@ -370,10 +370,10 @@ "params": 5 }, "packages/cloudflare/src/admin-worker.ts::createAdminWorker": { - "lines": 272 + "lines": 262 }, "packages/cloudflare/src/admin-worker.ts::createAdminWorker>fetch": { - "lines": 209 + "lines": 199 }, "packages/comments/src/client/comments.browser.js::enhance": { "lines": 205 From 632cf45aca8db7ca5a7e535bf50f319bf72ad201 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:02:21 +0300 Subject: [PATCH 2/5] fix(api): harden the MCP OAuth server after review (#144) Security review of RFC-0044 phase 2 (PR #176), nine items: - Access tokens carry `cloudhal_grant` (a provider extension) and /mcp accepts a token only for the live grant it names, so tokens issued before a revoke stay dead after the client is connected again. - Revoking a connection or removing a member deletes the provider's stored consent and revokes its refresh tokens in the same SQL batch as the grant change; before /oauth2/authorize, a stored consent with no live grant, or one the member's current role would widen, is forgotten so Studio's consent screen shows (no step-up loop after a promotion). The grant is written before the provider's consent call. - agentCredential() in agent-runtime: an OAuth connection is an agent, never the person behind it. set_embargo through a token waits for a person instead of recording the person's approval; elevations stay key-only. - The consent forward keeps the trusted client-address header, so the rate limit counts per client. - No DPoP in v1: proofs refused at /oauth2/token, not advertised, and tokens with `cnf` refused on /mcp. - Refresh token families end 90 days after the authentication they began with (the provider's token hash is now Cloudhal's too). - The consent screen starts a step-up from the existing grant and marks new scopes; its wording no longer promises Phase 3 features. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- apps/studio/src/i18n/ar.ts | 6 +- apps/studio/src/i18n/de.ts | 6 +- apps/studio/src/i18n/en.ts | 4 +- apps/studio/src/i18n/es.ts | 6 +- apps/studio/src/i18n/fa.ts | 6 +- apps/studio/src/i18n/fr.ts | 6 +- apps/studio/src/i18n/generated/params.ts | 1 + apps/studio/src/i18n/he.ts | 7 +- apps/studio/src/i18n/hi.ts | 6 +- apps/studio/src/i18n/id.ts | 6 +- apps/studio/src/i18n/it.ts | 6 +- apps/studio/src/i18n/ja.ts | 6 +- apps/studio/src/i18n/ko.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.ar.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.de.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.en.ts | 3 +- apps/studio/src/i18n/lazy/oauthConsent.es.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.fa.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.fr.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.he.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.hi.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.id.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.it.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.ja.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.ko.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.nl.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.pl.ts | 5 +- .../src/i18n/lazy/oauthConsent.pt-BR.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.ru.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.tr.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.uk.ts | 5 +- .../src/i18n/lazy/oauthConsent.zh-Hans.ts | 5 +- apps/studio/src/i18n/nl.ts | 6 +- apps/studio/src/i18n/pl.ts | 6 +- apps/studio/src/i18n/pt-BR.ts | 6 +- apps/studio/src/i18n/ru.ts | 6 +- apps/studio/src/i18n/tr.ts | 6 +- apps/studio/src/i18n/uk.ts | 6 +- apps/studio/src/i18n/zh-Hans.ts | 6 +- apps/studio/src/routes/OAuthConsentPage.tsx | 51 +++-- apps/studio/test/oauth-consent.test.tsx | 30 +++ docs/adr/0045-mcp-authorization-server.md | 6 +- packages/agent-runtime/src/credentials.ts | 19 ++ packages/agent-runtime/src/index.ts | 1 + .../agent-runtime/test/credentials.test.ts | 16 ++ packages/api/src/auth.ts | 17 +- packages/api/src/auth/access-token.ts | 24 ++- packages/api/src/auth/agent-consent.ts | 18 +- packages/api/src/auth/agent-grants.ts | 12 +- packages/api/src/auth/deps.ts | 15 +- packages/api/src/auth/grants.ts | 22 +++ packages/api/src/auth/instance.ts | 19 +- packages/api/src/auth/oauth-deps.ts | 32 +--- packages/api/src/auth/oauth-hooks.ts | 99 ++++++++++ packages/api/src/auth/oauth.ts | 41 ++++- packages/api/src/auth/token-principal.ts | 4 +- packages/api/src/staff.ts | 21 ++- .../api/src/testing-mcp-oauth-hardening.ts | 174 ++++++++++++++++++ packages/api/src/testing-oauth-harness.ts | 13 +- packages/api/test/access-token.test.ts | 58 ++++++ packages/api/test/mcp-oauth-server.test.ts | 14 +- packages/mcp-server/src/elevation-tools.ts | 5 +- packages/mcp-server/src/gate-approval.ts | 31 ++++ packages/mcp-server/src/schedule-tools.ts | 25 +-- packages/mcp-server/test/schedules.test.ts | 24 +++ scripts/size-baseline.json | 4 +- 66 files changed, 789 insertions(+), 216 deletions(-) create mode 100644 packages/agent-runtime/src/credentials.ts create mode 100644 packages/agent-runtime/test/credentials.test.ts create mode 100644 packages/api/src/auth/oauth-hooks.ts create mode 100644 packages/api/src/testing-mcp-oauth-hardening.ts create mode 100644 packages/api/test/access-token.test.ts create mode 100644 packages/mcp-server/src/gate-approval.ts diff --git a/apps/studio/src/i18n/ar.ts b/apps/studio/src/i18n/ar.ts index 04ac709d..aeaeb0be 100644 --- a/apps/studio/src/i18n/ar.ts +++ b/apps/studio/src/i18n/ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 85836f8ccd597d42 +// hash: 44d1f7038905a304 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1227,7 +1227,7 @@ export const messages: Messages = { members: "الفريق والأدوار", agentKeys: "مفاتيح الوكلاء", mcp: "خادم MCP", - mcpOauth: "ربط الوكلاء عبر URL", + mcpOauth: "OAuth لعملاء MCP", activity: "سجل النشاط", previewLinks: "روابط المعاينة", deployTransfer: "التوزيع والنقل", @@ -1240,7 +1240,7 @@ export const messages: Messages = { agentKeys: "يسجّل الوكلاء ومهام CI الدخول بمفاتيح تنشئها أنت.", mcp: "يتصل الوكلاء على /mcp.", mcpOauth: - "تتصل Claude وChatGPT وعملاء MCP الآخرون بعنوان /mcp وحده؛ وأنت توافق على كل اتصال.", + "يسجّل عملاء MCP المسجّلون في هذا الـStudio الدخول إلى /mcp عبر OAuth؛ وأنت توافق على كل اتصال.", activity: "يُسجَّل كل استدعاء أداة لكل موقع.", previewLinks: "روابط موقّعة تعرض المسودات على الموقع المستضاف.", deployTransfer: "يمكن لـcloudhal push وpull الوصول إلى هذا الخادم.", diff --git a/apps/studio/src/i18n/de.ts b/apps/studio/src/i18n/de.ts index ce722103..0c934829 100644 --- a/apps/studio/src/i18n/de.ts +++ b/apps/studio/src/i18n/de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: daeaa96e378bb3ee +// hash: 009322cbc9ddcd5d import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1275,7 +1275,7 @@ export const messages: Messages = { members: "Team und Rollen", agentKeys: "Agent-Schlüssel", mcp: "MCP-Server", - mcpOauth: "Agents per URL verbinden", + mcpOauth: "OAuth für MCP-Clients", activity: "Aktivitätsprotokoll", previewLinks: "Vorschau-Links", deployTransfer: "Deployment und Übertragung", @@ -1288,7 +1288,7 @@ export const messages: Messages = { agentKeys: "Agents und CI-Jobs melden sich mit Schlüsseln an, die du erstellst.", mcp: "Agents verbinden sich unter /mcp.", mcpOauth: - "Claude, ChatGPT und andere MCP-Clients verbinden sich allein mit der /mcp-Adresse; du bestätigst jede Verbindung.", + "In diesem Studio registrierte MCP-Clients melden sich unter /mcp mit OAuth an; du bestätigst jede Verbindung.", activity: "Jeder Tool-Aufruf wird pro Website protokolliert.", previewLinks: "Signierte Links, die Entwürfe auf der bereitgestellten Website zeigen.", deployTransfer: "cloudhal push und pull können diesen Server erreichen.", diff --git a/apps/studio/src/i18n/en.ts b/apps/studio/src/i18n/en.ts index df374cd5..7e38934a 100644 --- a/apps/studio/src/i18n/en.ts +++ b/apps/studio/src/i18n/en.ts @@ -1194,7 +1194,7 @@ export const en = { members: "Team and roles", agentKeys: "Agent keys", mcp: "MCP server", - mcpOauth: "Connect agents by URL", + mcpOauth: "OAuth for MCP clients", activity: "Activity log", previewLinks: "Preview links", deployTransfer: "Deploy and transfer", @@ -1207,7 +1207,7 @@ export const en = { agentKeys: "Agents and CI jobs sign in with keys you create.", mcp: "Agents connect at /mcp.", mcpOauth: - "Claude, ChatGPT and other MCP clients connect with the /mcp address alone; you approve each connection.", + "MCP clients registered with this Studio sign in at /mcp with OAuth; you approve each connection.", activity: "Every tool call is recorded per site.", previewLinks: "Signed links that show drafts on the deployed site.", deployTransfer: "cloudhal push and pull can reach this server.", diff --git a/apps/studio/src/i18n/es.ts b/apps/studio/src/i18n/es.ts index fcbb4849..f2d36f25 100644 --- a/apps/studio/src/i18n/es.ts +++ b/apps/studio/src/i18n/es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 0f2cd4b34c9478e2 +// hash: a07985c5a97d4088 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1269,7 +1269,7 @@ export const messages: Messages = { members: "Equipo y roles", agentKeys: "Claves de agente", mcp: "Servidor MCP", - mcpOauth: "Conectar agentes por URL", + mcpOauth: "OAuth para clientes MCP", activity: "Registro de actividad", previewLinks: "Enlaces de vista previa", deployTransfer: "Implementación y transferencia", @@ -1282,7 +1282,7 @@ export const messages: Messages = { agentKeys: "Los agentes y los trabajos de CI inician sesión con claves que creas tú.", mcp: "Los agentes se conectan en /mcp.", mcpOauth: - "Claude, ChatGPT y otros clientes MCP se conectan solo con la dirección /mcp; tú apruebas cada conexión.", + "Los clientes MCP registrados en este Studio inician sesión en /mcp con OAuth; tú apruebas cada conexión.", activity: "Cada llamada a herramientas se registra por sitio.", previewLinks: "Enlaces firmados que muestran los borradores en el sitio implementado.", deployTransfer: "cloudhal push y pull pueden llegar a este servidor.", diff --git a/apps/studio/src/i18n/fa.ts b/apps/studio/src/i18n/fa.ts index 1e509581..b36437bd 100644 --- a/apps/studio/src/i18n/fa.ts +++ b/apps/studio/src/i18n/fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 192ba5182a0b1c83 +// hash: ec7b9eedf08692c6 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1248,7 +1248,7 @@ export const messages: Messages = { members: "تیم و نقش‌ها", agentKeys: "کلیدهای عامل", mcp: "سرور MCP", - mcpOauth: "اتصال عامل‌ها با URL", + mcpOauth: "OAuth برای کلاینت‌های MCP", activity: "گزارش فعالیت", previewLinks: "پیوندهای پیش‌نمایش", deployTransfer: "استقرار و انتقال", @@ -1261,7 +1261,7 @@ export const messages: Messages = { agentKeys: "عامل‌ها و کارهای CI با کلیدهایی که شما می‌سازید وارد می‌شوند.", mcp: "عامل‌ها در /mcp متصل می‌شوند.", mcpOauth: - "Claude، ChatGPT و دیگر کلاینت‌های MCP فقط با نشانی /mcp وصل می‌شوند؛ هر اتصال را شما تأیید می‌کنید.", + "کلاینت‌های MCP ثبت‌شده در این Studio با OAuth در /mcp وارد می‌شوند؛ هر اتصال را شما تأیید می‌کنید.", activity: "هر فراخوانی ابزار برای هر سایت ثبت می‌شود.", previewLinks: "پیوندهای امضاشده‌ای که پیش‌نویس‌ها را در سایت مستقرشده نشان می‌دهند.", deployTransfer: "cloudhal push و pull می‌توانند به این سرور برسند.", diff --git a/apps/studio/src/i18n/fr.ts b/apps/studio/src/i18n/fr.ts index 2142f2af..d6d301ef 100644 --- a/apps/studio/src/i18n/fr.ts +++ b/apps/studio/src/i18n/fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f0d31150d942eca9 +// hash: 804ad3e05719e4f5 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1288,7 +1288,7 @@ export const messages: Messages = { members: "Équipe et rôles", agentKeys: "Clés d’agent", mcp: "Serveur MCP", - mcpOauth: "Connecter des agents par URL", + mcpOauth: "OAuth pour les clients MCP", activity: "Journal d’activité", previewLinks: "Liens d’aperçu", deployTransfer: "Déploiement et transfert", @@ -1301,7 +1301,7 @@ export const messages: Messages = { agentKeys: "Les agents et jobs de CI se connectent avec des clés que vous créez.", mcp: "Les agents se connectent à /mcp.", mcpOauth: - "Claude, ChatGPT et d'autres clients MCP se connectent avec la seule adresse /mcp ; vous approuvez chaque connexion.", + "Les clients MCP enregistrés dans ce Studio se connectent à /mcp avec OAuth ; vous approuvez chaque connexion.", activity: "Chaque appel d’outil est consigné par site.", previewLinks: "Des liens signés qui montrent les brouillons sur le site déployé.", deployTransfer: "cloudhal push et pull peuvent joindre ce serveur.", diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 914d2968..8857c12c 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -545,6 +545,7 @@ export interface MessageParams { "notifications.roleChanged.title": { role: string | number }; "notifications.toast.endingBody": { time: string | number; from: string | number }; "notifications.toast.endingTitle": { name: string | number; mode: string | number; left: string | number }; + "oauthConsent.scopes.added": { label: string | number }; "oauthConsent.scopes.narrowed": { label: string | number; scope: string | number }; "oauthConsent.scopes.notInRole": { label: string | number }; "oauthConsent.scopes.sites": { count: number }; diff --git a/apps/studio/src/i18n/he.ts b/apps/studio/src/i18n/he.ts index 5cf95b2b..b876b1a4 100644 --- a/apps/studio/src/i18n/he.ts +++ b/apps/studio/src/i18n/he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f5a406ed081a3a33 +// hash: b942eaf4d59c74c4 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1223,7 +1223,7 @@ export const messages: Messages = { members: "צוות ותפקידים", agentKeys: "מפתחות סוכנים", mcp: "שרת MCP", - mcpOauth: "חיבור סוכנים לפי URL", + mcpOauth: "OAuth ללקוחות MCP", activity: "יומן פעילות", previewLinks: "קישורי תצוגה מקדימה", deployTransfer: "פריסה והעברה", @@ -1235,8 +1235,7 @@ export const messages: Messages = { "אנשים מצטרפים בקישור הזמנה ועובדים בתפקיד: בעלים, מנהל מערכת, עורך, כותב או תורם.", agentKeys: "סוכנים ומשימות CI מתחברים עם מפתחות שיוצרים עבורם.", mcp: "סוכנים מתחברים בכתובת /mcp.", - mcpOauth: - "Claude,‏ ChatGPT ולקוחות MCP אחרים מתחברים עם הכתובת /mcp בלבד; כל חיבור מאושר על ידך.", + mcpOauth: "לקוחות MCP הרשומים ב-Studio הזה נכנסים ל-/mcp עם OAuth; כל חיבור מאושר על ידך.", activity: "כל קריאה לכלי נרשמת לפי אתר.", previewLinks: "קישורים חתומים שמציגים טיוטות באתר הפרוס.", deployTransfer: "cloudhal push ו-pull יכולים להגיע לשרת הזה.", diff --git a/apps/studio/src/i18n/hi.ts b/apps/studio/src/i18n/hi.ts index e73245b4..97ddea27 100644 --- a/apps/studio/src/i18n/hi.ts +++ b/apps/studio/src/i18n/hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: f08d54d1a4ada98a +// hash: b397c1c1d1b280c9 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1218,7 +1218,7 @@ export const messages: Messages = { members: "टीम और भूमिकाएँ", agentKeys: "एजेंट कुंजियाँ", mcp: "MCP सर्वर", - mcpOauth: "URL से एजेंट कनेक्ट करें", + mcpOauth: "MCP क्लाइंट के लिए OAuth", activity: "गतिविधि लॉग", previewLinks: "प्रीव्यू लिंक", deployTransfer: "डिप्लॉय और ट्रांसफ़र", @@ -1231,7 +1231,7 @@ export const messages: Messages = { agentKeys: "एजेंट और CI जॉब आपके बनाए कुंजियों से साइन इन करते हैं।", mcp: "एजेंट /mcp पर कनेक्ट होते हैं।", mcpOauth: - "Claude, ChatGPT और दूसरे MCP क्लाइंट सिर्फ़ /mcp पते से कनेक्ट होते हैं; हर कनेक्शन आप मंज़ूर करते हैं।", + "इस Studio में रजिस्टर्ड MCP क्लाइंट /mcp पर OAuth से साइन इन करते हैं; हर कनेक्शन आप मंज़ूर करते हैं।", activity: "हर टूल कॉल प्रति साइट दर्ज होती है।", previewLinks: "हस्ताक्षरित लिंक जो डिप्लॉय की गई साइट पर ड्राफ़्ट दिखाते हैं।", deployTransfer: "cloudhal push और pull इस सर्वर तक पहुँच सकते हैं।", diff --git a/apps/studio/src/i18n/id.ts b/apps/studio/src/i18n/id.ts index 59991529..aebc9a47 100644 --- a/apps/studio/src/i18n/id.ts +++ b/apps/studio/src/i18n/id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 7fcb09869901645a +// hash: 84c3e70911fa8af6 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1264,7 +1264,7 @@ export const messages: Messages = { members: "Tim dan peran", agentKeys: "Kunci agen", mcp: "Server MCP", - mcpOauth: "Hubungkan agen lewat URL", + mcpOauth: "OAuth untuk klien MCP", activity: "Log aktivitas", previewLinks: "Tautan pratinjau", deployTransfer: "Deploy dan transfer", @@ -1277,7 +1277,7 @@ export const messages: Messages = { agentKeys: "Agen dan tugas CI masuk dengan kunci yang Anda buat.", mcp: "Agen terhubung di /mcp.", mcpOauth: - "Claude, ChatGPT, dan klien MCP lain terhubung hanya dengan alamat /mcp; Anda menyetujui setiap koneksi.", + "Klien MCP yang terdaftar di Studio ini masuk ke /mcp dengan OAuth; Anda menyetujui setiap koneksi.", activity: "Setiap panggilan alat dicatat per situs.", previewLinks: "Tautan bertanda tangan yang menampilkan draf di situs yang di-deploy.", deployTransfer: "cloudhal push dan pull dapat menjangkau server ini.", diff --git a/apps/studio/src/i18n/it.ts b/apps/studio/src/i18n/it.ts index c9d7b85a..cbed3630 100644 --- a/apps/studio/src/i18n/it.ts +++ b/apps/studio/src/i18n/it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: e478c29376b4d68b +// hash: 66f1cb9fa0ba33f5 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1264,7 +1264,7 @@ export const messages: Messages = { members: "Team e ruoli", agentKeys: "Chiavi agente", mcp: "Server MCP", - mcpOauth: "Collega agenti tramite URL", + mcpOauth: "OAuth per client MCP", activity: "Log delle attività", previewLinks: "Link di anteprima", deployTransfer: "Deploy e trasferimento", @@ -1277,7 +1277,7 @@ export const messages: Messages = { agentKeys: "Agenti e job di CI accedono con chiavi che crei tu.", mcp: "Gli agenti si collegano a /mcp.", mcpOauth: - "Claude, ChatGPT e altri client MCP si collegano con il solo indirizzo /mcp; approvi tu ogni connessione.", + "I client MCP registrati in questo Studio accedono a /mcp con OAuth; approvi tu ogni connessione.", activity: "Ogni chiamata a uno strumento viene registrata per sito.", previewLinks: "Link firmati che mostrano le bozze sul sito distribuito.", deployTransfer: "cloudhal push e pull possono raggiungere questo server.", diff --git a/apps/studio/src/i18n/ja.ts b/apps/studio/src/i18n/ja.ts index f15546a1..92341534 100644 --- a/apps/studio/src/i18n/ja.ts +++ b/apps/studio/src/i18n/ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9b9673e6d67b24d1 +// hash: b9d24833e6a1c099 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1257,7 +1257,7 @@ export const messages: Messages = { members: "チームとロール", agentKeys: "エージェントキー", mcp: "MCPサーバー", - mcpOauth: "URL でエージェントを接続", + mcpOauth: "MCP クライアント向け OAuth", activity: "アクティビティログ", previewLinks: "プレビューリンク", deployTransfer: "デプロイと転送", @@ -1270,7 +1270,7 @@ export const messages: Messages = { agentKeys: "エージェントやCIジョブは、あなたが作成したキーでサインインします。", mcp: "エージェントは/mcpで接続します。", mcpOauth: - "Claude、ChatGPT などの MCP クライアントは /mcp のアドレスだけで接続し、接続ごとにあなたが承認します。", + "この Studio に登録された MCP クライアントは /mcp に OAuth でサインインし、接続ごとにあなたが承認します。", activity: "すべてのツール呼び出しがサイトごとに記録されます。", previewLinks: "デプロイ済みのサイトで下書きを表示する署名付きリンクです。", deployTransfer: "cloudhal pushとpullでこのサーバーにアクセスできます。", diff --git a/apps/studio/src/i18n/ko.ts b/apps/studio/src/i18n/ko.ts index cac879a7..0981ec7c 100644 --- a/apps/studio/src/i18n/ko.ts +++ b/apps/studio/src/i18n/ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 23fad7f0119a599e +// hash: 207e3531378284cc import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1227,7 +1227,7 @@ export const messages: Messages = { members: "팀과 역할", agentKeys: "에이전트 키", mcp: "MCP 서버", - mcpOauth: "URL로 에이전트 연결", + mcpOauth: "MCP 클라이언트용 OAuth", activity: "활동 로그", previewLinks: "미리보기 링크", deployTransfer: "배포 및 전송", @@ -1240,7 +1240,7 @@ export const messages: Messages = { agentKeys: "에이전트와 CI 작업은 내가 만든 키로 로그인해요.", mcp: "에이전트는 /mcp로 연결해요.", mcpOauth: - "Claude, ChatGPT 등 MCP 클라이언트는 /mcp 주소만으로 연결하며, 연결마다 내가 승인합니다.", + "이 Studio에 등록된 MCP 클라이언트는 /mcp에서 OAuth로 로그인하며, 연결마다 내가 승인합니다.", activity: "모든 도구 호출이 사이트별로 기록돼요.", previewLinks: "배포된 사이트에서 초안을 보여 주는 서명된 링크예요.", deployTransfer: "cloudhal push와 pull로 이 서버에 접근할 수 있어요.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts index 0d7cd969..936fa00b 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ar `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 6022774733fe70c9 +// hash: d6d17d53696a6da9 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -28,7 +28,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "الأذونات", notInRole: "{label} (ليس ضمن دورك)", narrowed: "{label}، لعملك الخاص فقط ضمن دورك ({scope})", - offline: "يبقى متصلاً ويجدّد وصوله بنفسه حتى تفصله من صفحة الوكلاء.", + added: "{label} (جديد)", + offline: + "يبقى متصلاً ويجدّد وصوله بنفسه، لمدة 90 يومًا على الأكثر، حتى يُلغى الاتصال أو ينتهي وصولك.", sites: "{count, plural, zero {لا يصل إلى أي موقع.} one {يصل فقط إلى الموقع الذي تعمل عليه.} two {يصل فقط إلى الموقعين اللذين تعمل عليهما.} few {يصل فقط إلى # مواقع تعمل عليها.} many {يصل فقط إلى # موقعًا تعمل عليها.} other {يصل فقط إلى # موقع تعمل عليها.}}", none: "اختر إذنًا واحدًا على الأقل.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.de.ts b/apps/studio/src/i18n/lazy/oauthConsent.de.ts index 0167375b..f92f280d 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.de.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.de.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): de `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: aaa7f80c24fe29a7 +// hash: 07b0fd828ea3973f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Berechtigungen", notInRole: "{label} (nicht in deiner Rolle)", narrowed: "{label}, nur für deine eigene Arbeit in deiner Rolle ({scope})", + added: "{label} (neu)", offline: - "Er bleibt verbunden und erneuert seinen Zugriff selbst, bis du ihn auf der Seite Agents trennst.", + "Er bleibt verbunden und erneuert seinen Zugriff selbst, höchstens 90 Tage lang, bis die Verbindung widerrufen wird oder dein Zugang endet.", sites: "{count, plural, one {Er erreicht nur die Website, an der du arbeitest.} other {Er erreicht nur die # Websites, an denen du arbeitest.}}", none: "Wähle mindestens eine Berechtigung.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.en.ts b/apps/studio/src/i18n/lazy/oauthConsent.en.ts index c2f9fe2f..824f54f2 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.en.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.en.ts @@ -30,8 +30,9 @@ export const messages = { legend: "Permissions", notInRole: "{label} (not in your role)", narrowed: "{label}, only for your own work in your role ({scope})", + added: "{label} (new)", offline: - "It stays connected and renews its access by itself until you disconnect it on the Agents page.", + "It stays connected and renews its access by itself, for at most 90 days, until the connection is revoked or your access ends.", sites: "{count, plural, one {It reaches only the site you work on.} other {It reaches only the # sites you work on.}}", none: "Choose at least one permission.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.es.ts b/apps/studio/src/i18n/lazy/oauthConsent.es.ts index 5fe21a99..e66f7343 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.es.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.es.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): es `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: bfc09daf09ffae18 +// hash: 6e539e2b036bb8c6 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Permisos", notInRole: "{label} (no está en tu rol)", narrowed: "{label}, solo para tu propio trabajo en tu rol ({scope})", + added: "{label} (nuevo)", offline: - "Sigue conectado y renueva su acceso por sí mismo hasta que lo desconectes en la página Agentes.", + "Sigue conectado y renueva su acceso por sí mismo, durante 90 días como máximo, hasta que se revoque la conexión o termine tu acceso.", sites: "{count, plural, one {Solo llega al sitio en el que trabajas.} many {Solo llega a los # sitios en los que trabajas.} other {Solo llega a los # sitios en los que trabajas.}}", none: "Elige al menos un permiso.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts index d8f7c202..db08d4f8 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fa `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 28985d3e98482ebd +// hash: 9378e7d2a7816050 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "مجوزها", notInRole: "{label} (در نقش شما نیست)", narrowed: "{label}، فقط برای کار خودتان در نقش شما ({scope})", + added: "{label} (جدید)", offline: - "متصل می‌ماند و دسترسی‌اش را خودش تمدید می‌کند تا وقتی که در صفحهٔ عامل‌ها آن را قطع کنید.", + "متصل می‌ماند و دسترسی‌اش را خودش تمدید می‌کند، حداکثر ۹۰ روز، تا وقتی که اتصال لغو شود یا دسترسی شما تمام شود.", sites: "{count, plural, one {فقط به سایتی که روی آن کار می‌کنید دسترسی دارد.} other {فقط به # سایتی که روی آن‌ها کار می‌کنید دسترسی دارد.}}", none: "دست‌کم یک مجوز انتخاب کنید.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts index f1c7b2b2..fa02c1fb 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): fr `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a8abd1ef16e87cf4 +// hash: 63aa7946c1e148ca import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Autorisations", notInRole: "{label} (hors de votre rôle)", narrowed: "{label}, uniquement pour votre propre travail dans votre rôle ({scope})", + added: "{label} (nouveau)", offline: - "Il reste connecté et renouvelle son accès lui-même jusqu'à ce que vous le déconnectiez sur la page Agents.", + "Il reste connecté et renouvelle son accès lui-même, pendant 90 jours au plus, jusqu'à ce que la connexion soit révoquée ou que votre accès prenne fin.", sites: "{count, plural, one {Il n'atteint que le site sur lequel vous travaillez.} many {Il n'atteint que les # sites sur lesquels vous travaillez.} other {Il n'atteint que les # sites sur lesquels vous travaillez.}}", none: "Choisissez au moins une autorisation.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.he.ts b/apps/studio/src/i18n/lazy/oauthConsent.he.ts index a9c1a931..004d3d06 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.he.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.he.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): he `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d8372bd1416a32fd +// hash: 37c0b05241b08508 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -29,7 +29,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "הרשאות", notInRole: "{label} (לא בתפקיד שלך)", narrowed: "{label}, רק לעבודה שלך בתפקיד שלך ({scope})", - offline: "הוא נשאר מחובר ומחדש את הגישה בעצמו עד שתנתק אותו בדף הסוכנים.", + added: "{label} (חדש)", + offline: + "הוא נשאר מחובר ומחדש את הגישה בעצמו, לכל היותר 90 יום, עד שהחיבור יבוטל או שהגישה שלך תסתיים.", sites: "{count, plural, one {הוא מגיע רק לאתר שאת/ה עובד/ת עליו.} two {הוא מגיע רק לשני האתרים שאת/ה עובד/ת עליהם.} other {הוא מגיע רק ל-# האתרים שאת/ה עובד/ת עליהם.}}", none: "יש לבחור לפחות הרשאה אחת.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts index ea550d41..5bdf098e 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): hi `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: e1a45ae90aaa4022 +// hash: b558b0bef4ac3ed0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "अनुमतियां", notInRole: "{label} (आपकी भूमिका में नहीं)", narrowed: "{label}, आपकी भूमिका में सिर्फ़ आपके अपने काम के लिए ({scope})", + added: "{label} (नया)", offline: - "वह जुड़ा रहता है और अपनी पहुंच खुद नवीनीकृत करता है, जब तक आप उसे एजेंट पेज पर डिस्कनेक्ट नहीं करते।", + "वह जुड़ा रहता है और अपनी पहुंच खुद नवीनीकृत करता है, ज़्यादा से ज़्यादा 90 दिन तक, जब तक कनेक्शन रद्द न हो या आपकी पहुंच खत्म न हो।", sites: "{count, plural, one {वह सिर्फ़ उस साइट तक पहुंचता है जिस पर आप काम करते हैं।} other {वह सिर्फ़ उन # साइटों तक पहुंचता है जिन पर आप काम करते हैं।}}", none: "कम से कम एक अनुमति चुनें।", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.id.ts b/apps/studio/src/i18n/lazy/oauthConsent.id.ts index 565a7305..a8166525 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.id.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.id.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): id `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: de9dd0dedff197ca +// hash: 8b80d49448b0fcea import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Izin", notInRole: "{label} (tidak ada di peran Anda)", narrowed: "{label}, hanya untuk pekerjaan Anda sendiri dalam peran Anda ({scope})", + added: "{label} (baru)", offline: - "Agen tetap terhubung dan memperbarui aksesnya sendiri sampai Anda memutuskannya di halaman Agen.", + "Agen tetap terhubung dan memperbarui aksesnya sendiri, paling lama 90 hari, sampai koneksi dicabut atau akses Anda berakhir.", sites: "{count, plural, other {Agen hanya menjangkau # situs yang Anda kerjakan.}}", none: "Pilih minimal satu izin.", }, diff --git a/apps/studio/src/i18n/lazy/oauthConsent.it.ts b/apps/studio/src/i18n/lazy/oauthConsent.it.ts index f546c341..b2cc5ed4 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.it.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.it.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): it `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: a79e81130bc3f414 +// hash: 0b67d5b2bc3557b1 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Autorizzazioni", notInRole: "{label} (non nel tuo ruolo)", narrowed: "{label}, solo per il tuo lavoro nel tuo ruolo ({scope})", + added: "{label} (nuovo)", offline: - "Resta collegato e rinnova da solo l'accesso finché non lo scolleghi nella pagina Agenti.", + "Resta collegato e rinnova da solo l'accesso, per al massimo 90 giorni, finché la connessione non viene revocata o il tuo accesso non termina.", sites: "{count, plural, one {Raggiunge solo il sito su cui lavori.} many {Raggiunge solo i # siti su cui lavori.} other {Raggiunge solo i # siti su cui lavori.}}", none: "Scegli almeno un'autorizzazione.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts index a07f96a6..cbdbe6f4 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ja `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 71ce1f23d0f2bda3 +// hash: d97237187f90ddf0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,7 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "権限", notInRole: "{label}(あなたのロールにはありません)", narrowed: "{label}(あなたのロールでは自分の作業のみ: {scope})", - offline: "エージェントページで切断するまで接続したままで、アクセスを自動で更新します。", + added: "{label}(新規)", + offline: + "接続が取り消されるかあなたのアクセスが終わるまで、最長 90 日間は接続したままで、アクセスを自動で更新します。", sites: "{count, plural, other {あなたが担当する # 件のサイトにのみアクセスします。}}", none: "権限を 1 つ以上選んでください。", }, diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts index c52d782c..1273a411 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ko `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 2f739dfc56ae74cf +// hash: c65704fd372347a7 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "권한", notInRole: "{label} (내 역할에 없음)", narrowed: "{label}, 내 역할에서 내 작업에만 ({scope})", + added: "{label} (새 권한)", offline: - "에이전트 페이지에서 연결을 끊을 때까지 연결된 상태로 남아 접근 권한을 스스로 갱신합니다.", + "연결이 취소되거나 내 접근 권한이 끝날 때까지, 최대 90일 동안 연결된 상태로 남아 접근 권한을 스스로 갱신합니다.", sites: "{count, plural, other {내가 작업하는 사이트 #개에만 접근합니다.}}", none: "권한을 하나 이상 선택하세요.", }, diff --git a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts index e9dd1dcd..334b3fc1 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 3b961d3076828d00 +// hash: 0cc1edc4ecc6e454 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Rechten", notInRole: "{label} (niet in je rol)", narrowed: "{label}, alleen voor je eigen werk in je rol ({scope})", + added: "{label} (nieuw)", offline: - "Hij blijft verbonden en vernieuwt zijn toegang zelf tot je hem op de pagina Agents loskoppelt.", + "Hij blijft verbonden en vernieuwt zijn toegang zelf, hooguit 90 dagen, tot de verbinding wordt ingetrokken of je toegang eindigt.", sites: "{count, plural, one {Hij bereikt alleen de site waaraan je werkt.} other {Hij bereikt alleen de # sites waaraan je werkt.}}", none: "Kies minstens één recht.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts index 27471c5d..acc22e3a 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 50204e4651a82668 +// hash: b4c66e63431ba80f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -31,8 +31,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Uprawnienia", notInRole: "{label} (poza twoją rolą)", narrowed: "{label}, tylko do twojej własnej pracy w twojej roli ({scope})", + added: "{label} (nowe)", offline: - "Pozostaje połączony i sam odnawia dostęp, dopóki nie odłączysz go na stronie Agenci.", + "Pozostaje połączony i sam odnawia dostęp, najwyżej przez 90 dni, dopóki połączenie nie zostanie odwołane albo twój dostęp się nie skończy.", sites: "{count, plural, one {Ma dostęp tylko do witryny, nad którą pracujesz.} few {Ma dostęp tylko do # witryn, nad którymi pracujesz.} many {Ma dostęp tylko do # witryn, nad którymi pracujesz.} other {Ma dostęp tylko do # witryny, nad którymi pracujesz.}}", none: "Wybierz co najmniej jedno uprawnienie.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts index 0931ac0f..7d85e344 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9196a55f7b64f7db +// hash: 2356f90339549047 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Permissões", notInRole: "{label} (fora da sua função)", narrowed: "{label}, só para o seu próprio trabalho na sua função ({scope})", + added: "{label} (novo)", offline: - "Ele continua conectado e renova o acesso sozinho até você desconectá-lo na página Agentes.", + "Ele continua conectado e renova o acesso sozinho, por no máximo 90 dias, até a conexão ser revogada ou seu acesso terminar.", sites: "{count, plural, one {Ele só alcança o site em que você trabalha.} many {Ele só alcança os # sites em que você trabalha.} other {Ele só alcança os # sites em que você trabalha.}}", none: "Escolha pelo menos uma permissão.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts index c07be78c..c9130bba 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 43ea63682e1ce2ec +// hash: 48e760099b8a5100 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Разрешения", notInRole: "{label} (нет в вашей роли)", narrowed: "{label}, только для вашей собственной работы в вашей роли ({scope})", + added: "{label} (новое)", offline: - "Он остаётся подключённым и сам продлевает доступ, пока вы не отключите его на странице «Агенты».", + "Он остаётся подключённым и сам продлевает доступ, не дольше 90 дней, пока подключение не отзовут или ваш доступ не закончится.", sites: "{count, plural, one {Он видит только # сайт, с которым вы работаете.} few {Он видит только # сайта, с которыми вы работаете.} many {Он видит только # сайтов, с которыми вы работаете.} other {Он видит только # сайта, с которыми вы работаете.}}", none: "Выберите хотя бы одно разрешение.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts index f9f44955..9e21eb2a 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 2bef83f2b61322ba +// hash: ed9f39fdef85a60c import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "İzinler", notInRole: "{label} (rolünde yok)", narrowed: "{label}, rolünde yalnızca kendi işin için ({scope})", + added: "{label} (yeni)", offline: - "Sen Agent'lar sayfasından bağlantısını kesene kadar bağlı kalır ve erişimini kendisi yeniler.", + "Bağlantı geri alınana ya da erişimin bitene kadar, en fazla 90 gün boyunca bağlı kalır ve erişimini kendisi yeniler.", sites: "{count, plural, one {Yalnızca üzerinde çalıştığın siteye erişir.} other {Yalnızca üzerinde çalıştığın {count, number} siteye erişir.}}", none: "En az bir izin seç.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts index 4bcb5898..f6151423 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: cbcee1aeb800d486 +// hash: 7666c9230f67d63a import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -30,8 +30,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "Дозволи", notInRole: "{label} (немає у вашій ролі)", narrowed: "{label}, лише для вашої власної роботи у вашій ролі ({scope})", + added: "{label} (нове)", offline: - "Він лишається під'єднаним і сам поновлює доступ, доки ви не від'єднаєте його на сторінці «Агенти».", + "Він лишається під'єднаним і сам поновлює доступ, не довше 90 днів, доки під'єднання не відкличуть або ваш доступ не завершиться.", sites: "{count, plural, one {Він бачить лише # сайт, з яким ви працюєте.} few {Він бачить лише # сайти, з якими ви працюєте.} many {Він бачить лише # сайтів, з якими ви працюєте.} other {Він бачить лише # сайту, з якими ви працюєте.}}", none: "Виберіть принаймні один дозвіл.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts index 69fc14f1..98cad255 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans `oauthConsent.*` messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: b940b3fd25633015 +// hash: 1ac1987970d2db80 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { @@ -27,7 +27,8 @@ export const messages: NamespaceMessages<"oauthConsent"> = { legend: "权限", notInRole: "{label}(不在你的角色中)", narrowed: "{label},在你的角色中仅限你自己的工作({scope})", - offline: "它会保持连接并自行续期访问权限,直到你在“代理”页面断开它。", + added: "{label}(新增)", + offline: "它会保持连接并自行续期访问权限,最长 90 天,直到连接被撤销或你的访问权限结束。", sites: "{count, plural, other {它只能访问你参与的 # 个站点。}}", none: "请至少选择一项权限。", }, diff --git a/apps/studio/src/i18n/nl.ts b/apps/studio/src/i18n/nl.ts index 64c345af..6dbf0c5b 100644 --- a/apps/studio/src/i18n/nl.ts +++ b/apps/studio/src/i18n/nl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): nl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 16c404ee0eeeafc4 +// hash: 6358f358e5e2e9bf import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1268,7 +1268,7 @@ export const messages: Messages = { members: "Team en rollen", agentKeys: "Agentsleutels", mcp: "MCP-server", - mcpOauth: "Agents verbinden via URL", + mcpOauth: "OAuth voor MCP-clients", activity: "Activiteitenlog", previewLinks: "Voorbeeldlinks", deployTransfer: "Deployen en overzetten", @@ -1281,7 +1281,7 @@ export const messages: Messages = { agentKeys: "Agents en CI-jobs loggen in met sleutels die jij maakt.", mcp: "Agents verbinden via /mcp.", mcpOauth: - "Claude, ChatGPT en andere MCP-clients verbinden met alleen het /mcp-adres; jij keurt elke verbinding goed.", + "MCP-clients die bij deze Studio zijn geregistreerd, melden zich aan op /mcp met OAuth; jij keurt elke verbinding goed.", activity: "Elke toolaanroep wordt per site vastgelegd.", previewLinks: "Ondertekende links die concepten op de gedeployde site tonen.", deployTransfer: "cloudhal push en pull kunnen deze server bereiken.", diff --git a/apps/studio/src/i18n/pl.ts b/apps/studio/src/i18n/pl.ts index b1a2a331..61790531 100644 --- a/apps/studio/src/i18n/pl.ts +++ b/apps/studio/src/i18n/pl.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pl core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: d13a8791449088fd +// hash: 92b591cb8233cfb6 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1250,7 +1250,7 @@ export const messages: Messages = { members: "Zespół i role", agentKeys: "Klucze agentów", mcp: "Serwer MCP", - mcpOauth: "Łączenie agentów przez URL", + mcpOauth: "OAuth dla klientów MCP", activity: "Dziennik aktywności", previewLinks: "Linki podglądu", deployTransfer: "Wdrażanie i transfer", @@ -1263,7 +1263,7 @@ export const messages: Messages = { agentKeys: "Agenci i zadania CI logują się kluczami, które tworzysz.", mcp: "Agenci łączą się pod /mcp.", mcpOauth: - "Claude, ChatGPT i inni klienci MCP łączą się samym adresem /mcp; każde połączenie zatwierdzasz ty.", + "Klienci MCP zarejestrowani w tym Studio logują się w /mcp przez OAuth; każde połączenie zatwierdzasz ty.", activity: "Każde wywołanie narzędzia jest zapisywane dla każdej witryny.", previewLinks: "Podpisane linki pokazujące szkice na wdrożonej witrynie.", deployTransfer: "cloudhal push i pull mają dostęp do tego serwera.", diff --git a/apps/studio/src/i18n/pt-BR.ts b/apps/studio/src/i18n/pt-BR.ts index e360fbb5..0ef9f897 100644 --- a/apps/studio/src/i18n/pt-BR.ts +++ b/apps/studio/src/i18n/pt-BR.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): pt-BR core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 95432c1c202d09be +// hash: cc0bf81b7d135b50 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1256,7 +1256,7 @@ export const messages: Messages = { members: "Equipe e funções", agentKeys: "Chaves de agente", mcp: "Servidor MCP", - mcpOauth: "Conectar agentes por URL", + mcpOauth: "OAuth para clientes MCP", activity: "Log de atividades", previewLinks: "Links de pré-visualização", deployTransfer: "Implantação e transferência", @@ -1269,7 +1269,7 @@ export const messages: Messages = { agentKeys: "Agentes e jobs de CI entram com chaves que você cria.", mcp: "Os agentes se conectam em /mcp.", mcpOauth: - "Claude, ChatGPT e outros clientes MCP se conectam só com o endereço /mcp; você aprova cada conexão.", + "Clientes MCP registrados neste Studio entram em /mcp com OAuth; você aprova cada conexão.", activity: "Toda chamada de ferramenta é registrada por site.", previewLinks: "Links assinados que mostram rascunhos no site implantado.", deployTransfer: "cloudhal push e pull conseguem acessar este servidor.", diff --git a/apps/studio/src/i18n/ru.ts b/apps/studio/src/i18n/ru.ts index 623bffe2..c77a297b 100644 --- a/apps/studio/src/i18n/ru.ts +++ b/apps/studio/src/i18n/ru.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): ru core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9b2c14d813a0667c +// hash: 14637c0642468649 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1272,7 +1272,7 @@ export const messages: Messages = { members: "Команда и роли", agentKeys: "Ключи агентов", mcp: "MCP-сервер", - mcpOauth: "Подключение агентов по URL", + mcpOauth: "OAuth для MCP-клиентов", activity: "Журнал активности", previewLinks: "Ссылки предпросмотра", deployTransfer: "Развёртывание и перенос", @@ -1285,7 +1285,7 @@ export const messages: Messages = { agentKeys: "Агенты и задачи CI входят с помощью созданных вами ключей.", mcp: "Агенты подключаются по адресу /mcp.", mcpOauth: - "Claude, ChatGPT и другие MCP-клиенты подключаются только по адресу /mcp; каждое подключение подтверждаете вы.", + "MCP-клиенты, зарегистрированные в этом Studio, входят на /mcp через OAuth; каждое подключение подтверждаете вы.", activity: "Каждый вызов инструмента записывается для каждого сайта.", previewLinks: "Подписанные ссылки, показывающие черновики на развёрнутом сайте.", deployTransfer: "cloudhal push и pull могут подключаться к этому серверу.", diff --git a/apps/studio/src/i18n/tr.ts b/apps/studio/src/i18n/tr.ts index 9c199c33..928ae062 100644 --- a/apps/studio/src/i18n/tr.ts +++ b/apps/studio/src/i18n/tr.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): tr core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 1aa67e6b30d91685 +// hash: 4ac5258269d4a6a1 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1248,7 +1248,7 @@ export const messages: Messages = { members: "Ekip ve roller", agentKeys: "Agent anahtarları", mcp: "MCP sunucusu", - mcpOauth: "Agent'ları URL ile bağla", + mcpOauth: "MCP istemcileri için OAuth", activity: "Etkinlik kaydı", previewLinks: "Önizleme bağlantıları", deployTransfer: "Dağıtım ve aktarım", @@ -1261,7 +1261,7 @@ export const messages: Messages = { agentKeys: "Agent'lar ve CI işleri oluşturduğun anahtarlarla giriş yapar.", mcp: "Agent'lar /mcp adresinden bağlanır.", mcpOauth: - "Claude, ChatGPT ve diğer MCP istemcileri yalnızca /mcp adresiyle bağlanır; her bağlantıyı sen onaylarsın.", + "Bu Studio'ya kayıtlı MCP istemcileri /mcp adresinde OAuth ile oturum açar; her bağlantıyı sen onaylarsın.", activity: "Her araç çağrısı site bazında kaydedilir.", previewLinks: "Dağıtılmış sitede taslakları gösteren imzalı bağlantılar.", deployTransfer: "cloudhal push ve pull bu sunucuya ulaşabilir.", diff --git a/apps/studio/src/i18n/uk.ts b/apps/studio/src/i18n/uk.ts index 291a91fe..b40d8f99 100644 --- a/apps/studio/src/i18n/uk.ts +++ b/apps/studio/src/i18n/uk.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): uk core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 9b0ab250b5bc85c2 +// hash: 5e8a598de3725cb2 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1258,7 +1258,7 @@ export const messages: Messages = { members: "Команда й ролі", agentKeys: "Ключі агентів", mcp: "Сервер MCP", - mcpOauth: "Під'єднання агентів за URL", + mcpOauth: "OAuth для MCP-клієнтів", activity: "Журнал активності", previewLinks: "Посилання для перегляду", deployTransfer: "Розгортання й перенесення", @@ -1271,7 +1271,7 @@ export const messages: Messages = { agentKeys: "Агенти та завдання CI входять за допомогою створених вами ключів.", mcp: "Агенти підключаються за адресою /mcp.", mcpOauth: - "Claude, ChatGPT та інші MCP-клієнти під'єднуються лише за адресою /mcp; кожне під'єднання підтверджуєте ви.", + "MCP-клієнти, зареєстровані в цьому Studio, входять на /mcp через OAuth; кожне під'єднання підтверджуєте ви.", activity: "Кожен виклик інструмента записується для кожного сайту.", previewLinks: "Підписані посилання, що показують чернетки на розгорнутому сайті.", deployTransfer: "cloudhal push і pull можуть звертатися до цього сервера.", diff --git a/apps/studio/src/i18n/zh-Hans.ts b/apps/studio/src/i18n/zh-Hans.ts index bab392e6..96b6f36f 100644 --- a/apps/studio/src/i18n/zh-Hans.ts +++ b/apps/studio/src/i18n/zh-Hans.ts @@ -1,6 +1,6 @@ // Generated by `pnpm i18n:import` (ADR-0034): zh-Hans core messages. // Do not edit: change the translation file and import it; only English is edited here. -// hash: 62ed1a1f5398d8d1 +// hash: 434bc2a6d650eed7 import type { Messages } from "./en.ts"; export const messages: Messages = { @@ -1187,7 +1187,7 @@ export const messages: Messages = { members: "团队和角色", agentKeys: "智能体密钥", mcp: "MCP 服务器", - mcpOauth: "通过 URL 连接代理", + mcpOauth: "面向 MCP 客户端的 OAuth", activity: "动态日志", previewLinks: "预览链接", deployTransfer: "部署和传输", @@ -1198,7 +1198,7 @@ export const messages: Messages = { members: "成员通过邀请链接加入,并担任某个角色:所有者、管理员、编辑者、作者或投稿者。", agentKeys: "智能体和 CI 任务使用你创建的密钥登录。", mcp: "智能体在 /mcp 连接。", - mcpOauth: "Claude、ChatGPT 等 MCP 客户端只需 /mcp 地址即可连接;每次连接都由你批准。", + mcpOauth: "在此 Studio 注册的 MCP 客户端通过 OAuth 登录 /mcp;每次连接都由你批准。", activity: "每次工具调用都按站点记录。", previewLinks: "在已部署站点上显示草稿的签名链接。", deployTransfer: "cloudhal push 和 pull 可以访问此服务器。", diff --git a/apps/studio/src/routes/OAuthConsentPage.tsx b/apps/studio/src/routes/OAuthConsentPage.tsx index f569555f..1eb5ad2e 100644 --- a/apps/studio/src/routes/OAuthConsentPage.tsx +++ b/apps/studio/src/routes/OAuthConsentPage.tsx @@ -18,7 +18,13 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { AutoModeConfirm, ModePicker } from "../components/AgentModes.tsx"; import { useT } from "../i18n/index.tsx"; -import { type AgentScope, api, type ConsentInfo, type KeyMode } from "../lib/api.ts"; +import { + type AgentScope, + api, + type ConsentInfo, + type ConsentScope, + type KeyMode, +} from "../lib/api.ts"; import { apiErrorText } from "../lib/api-errors.ts"; /** The signed authorization request, untouched (re-encoding it would break the signature). */ @@ -63,19 +69,43 @@ export function OAuthConsentPage() { ); } +/** + * How each requested scope reads, and whether it starts ticked: a step-up starts from what the + * connection may do today; what it newly asks for is marked and left unticked. + */ +function useScopeLabels(info: ConsentInfo) { + const t = useT(); + const c = useT("oauthConsent"); + const granted = (s: ConsentScope) => + s.granted !== null && (info.existing?.scopes.includes(s.granted) ?? true); + // The scope's description, as the agent-key screens word it (`*` never reaches a token). + const label = (scope: AgentScope) => + scope === "*" ? t("agents.fullAccess") : t(`agents.scopes.${scope}`); + const scopeLabel = (s: ConsentScope): string => { + if (s.granted === null) + return c("oauthConsent.scopes.notInRole", { label: label(s.requested) }); + const text = + s.granted !== s.requested + ? c("oauthConsent.scopes.narrowed", { label: label(s.requested), scope: s.granted }) + : label(s.requested); + return info.existing && !granted(s) ? c("oauthConsent.scopes.added", { label: text }) : text; + }; + return { granted, scopeLabel }; +} + function ConsentForm({ request, info }: { request: string; info: ConsentInfo }) { const t = useT(); const c = useT("oauthConsent"); const grantable = info.scopes.filter((s) => s.granted !== null).map((s) => s.requested); - const [chosen, setChosen] = useState(grantable); + const { granted, scopeLabel } = useScopeLabels(info); + const [chosen, setChosen] = useState( + info.scopes.filter(granted).map((s) => s.requested), + ); const [mode, setMode] = useState(info.defaultMode); // Auto publishes without a person: explicit consent first, as for a key (DESIGN.md §2g). const [autoConfirmed, setAutoConfirmed] = useState(false); const [submitted, setSubmitted] = useState(false); const needsConsent = mode === "auto" && info.existing?.mode !== "auto"; - // The scope's description, as the agent-key screens word it (`*` never reaches a token). - const label = (scope: AgentScope) => - scope === "*" ? t("agents.fullAccess") : t(`agents.scopes.${scope}`); const decide = useMutation({ mutationFn: (accept: boolean) => api.agentGrants.consent({ request, accept, scopes: accept ? chosen : [], mode }), @@ -130,16 +160,7 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo }) key={s.requested} value={s.requested} disabled={s.granted === null} - label={ - s.granted === null - ? c("oauthConsent.scopes.notInRole", { label: label(s.requested) }) - : s.granted !== s.requested - ? c("oauthConsent.scopes.narrowed", { - label: label(s.requested), - scope: s.granted, - }) - : label(s.requested) - } + label={scopeLabel(s)} /> ))} diff --git a/apps/studio/test/oauth-consent.test.tsx b/apps/studio/test/oauth-consent.test.tsx index bf3c153a..e1dbab6a 100644 --- a/apps/studio/test/oauth-consent.test.tsx +++ b/apps/studio/test/oauth-consent.test.tsx @@ -103,6 +103,36 @@ describe("OAuth consent screen", () => { expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toMatchObject({ mode: "auto" }); }); + it("starts a step-up from what the connection may do today and marks what is new", async () => { + const fetch = serve( + ...asRole("editor"), + on("GET", "/api/agent-grants/consent", { + status: 200, + body: { + ...INFO, + scopes: [ + { requested: "site:read", granted: "site:read" }, + { requested: "content:read", granted: "content:read" }, + ], + existing: { scopes: ["site:read"], mode: "edits" }, + }, + }), + on("POST", "/api/agent-grants/consent", { + status: 200, + body: { redirect: "http://127.0.0.1:33418/callback?code=x" }, + }), + ); + renderPage(); + const added = await screen.findByRole("checkbox", { name: /\(new\)/ }); + expect(added.getAttribute("aria-checked")).toBe("false"); + expect(screen.getAllByRole("checkbox", { name: /\(new\)/ })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + await waitFor(() => expect(assign).toHaveBeenCalled()); + expect(bodyOf(fetch, "POST", "/api/agent-grants/consent")).toMatchObject({ + scopes: ["site:read"], + }); + }); + it("denies without scopes", async () => { const fetch = serve( ...asRole("editor"), diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index caa212d0..f053b1e8 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -9,9 +9,9 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve ## Decision 1. **Better Auth's `mcp()` plugin plus `jwt()`** in `createInstance` (`packages/api/src/auth/oauth.ts`), only when the runtime passes `AuthOptions.mcpOauth`. The issuer is Better Auth's base URL on the public origin, `/api/auth`; the resource is `/mcp`. Grants: authorization code with PKCE (S256 only) and refresh token; scopes are key scopes plus `offline_access`, no `openid`. The AS metadata is served by `createApi` at `/.well-known/oauth-authorization-server/api/auth` and at the root alias (Decision 6). -2. **Tokens:** RFC 9068 JWT access tokens (`typ: at+jwt`, EdDSA/Ed25519, 1 h), `aud` = the resource, `iss` = the issuer; refresh tokens for 30 d, rotated on every use with `mcp()`'s 30 s reuse window (Decision 2); authorization codes 5 min. `/mcp` verifies signature, `iss`, `aud`, `exp` and `typ` with `jose` against the `jwks` table read through the instance (cached per isolate by `kid`, never fetched over HTTP), pinned to EdDSA. -3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row or no membership is a 401 with the `invalid_token` challenge. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged. -4. **Consent is Cloudhal's.** The provider's `consentPage` is Studio's `/oauth/consent`; the screen posts to `/api/agent-grants/consent`, which verifies the signed request, narrows the requested scopes by the member's **installation** role (the cap #173's step-up relies on), caps the mode, calls the provider's consent endpoint with the scopes kept (a subset of the request, as the provider requires), then writes the grant, `agent_owner` and the `connect_agent` audit row in one batch. The provider's own `/oauth2/consent` refuses HTTP callers (a per-instance server key) and is fenced in `createApi` with every other provider endpoint that is not authorize, token or revoke (client and consent CRUD, introspection, userinfo, registration, the `jwt()` plugin's `/token`). +2. **Tokens:** RFC 9068 JWT access tokens (`typ: at+jwt`, EdDSA/Ed25519, 1 h), `aud` = the resource, `iss` = the issuer, and a private claim `cloudhal_grant` naming the grant they were issued for (a provider extension adds it from the live `agent_grants` row); refresh tokens for 30 d sliding, rotated on every use with `mcp()`'s 30 s reuse window (Decision 2), and refused 90 d after the authentication the family began with (`oauth-hooks.ts`, §2 "90 d absolute"); authorization codes 5 min. `/mcp` verifies signature, `iss`, `aud`, `exp`, `typ` and the grant claim with `jose` against the `jwks` table read through the instance (cached per isolate by `kid`, never fetched over HTTP), pinned to EdDSA. **No DPoP in v1:** the token endpoint refuses a `DPoP` proof, the metadata does not advertise DPoP algorithms, and `/mcp` refuses any token carrying `cnf`. +3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row, a row of a later grant than the token's `cloudhal_grant` (revoked, then connected again) or no membership is a 401 with the `invalid_token` challenge. Ending a connection (Studio's revoke, or removing the member) revokes the grant, deletes the provider's stored consent and revokes its refresh tokens in one SQL batch. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged. +4. **Consent is Cloudhal's.** The provider's `consentPage` is Studio's `/oauth/consent`; the screen posts to `/api/agent-grants/consent`, which verifies the signed request, narrows the requested scopes by the member's **installation** role (the cap #173's step-up relies on), caps the mode, calls the provider's consent endpoint with the scopes kept (a subset of the request, as the provider requires), writing the grant, `agent_owner` and the `connect_agent` audit row in one batch before it calls the provider. Before the provider's authorize step, Cloudhal deletes the provider's stored consent for the member and client when no live grant exists, or when the member's current role would give a requested scope the grant does not hold (a promotion followed by a step-up), so the provider's consent memory (Decision 8) never skips Studio's screen when the grant would change. Agent code that gives a key something of its own (an `auto` release approval, an elevation, an automation run) asks `agentCredential()` and never treats a connection as the person behind it. The provider's own `/oauth2/consent` refuses HTTP callers (a per-instance server key) and is fenced in `createApi` with every other provider endpoint that is not authorize, token or revoke (client and consent CRUD, introspection, userinfo, registration, the `jwt()` plugin's `/token`). 5. **No authorization server without a stable origin** (`mcpOauthFor`): the admin Worker uses the request's origin (as Better Auth's `baseURL` already does); `cloudhal studio` needs `--base-url` / `CLOUDHAL_BASE_URL` and logs why OAuth is off otherwise; plain HTTP is accepted on loopback only; `CLOUDHAL_MCP_OAUTH=off` turns it off on both. The playground stays out (Decision 10). 6. **Registration is Phase 3.** Phase 2 has no CIMD and no DCR; `OauthServer.createClient` (server-side only) creates a pre-registered public client for tests, the provider's `clientPrivileges` admitting only the server's own calls. diff --git a/packages/agent-runtime/src/credentials.ts b/packages/agent-runtime/src/credentials.ts new file mode 100644 index 00000000..c969a6ff --- /dev/null +++ b/packages/agent-runtime/src/credentials.ts @@ -0,0 +1,19 @@ +/** + * Which credential an agent principal works with (RFC-0044 §5 inv. 5): an API key (`keyId`) or + * an OAuth connection (`grantId`). Both are agents. Code that gives a key something of its own + * (an `auto` release approval, an elevation, an automation run) asks here instead of testing + * `keyId` alone, so a connection is never mistaken for the person behind it. + */ +import type { Principal } from "./policy.ts"; + +export type AgentCredential = + | { readonly kind: "key"; readonly id: string } + | { readonly kind: "grant"; readonly id: string }; + +/** The remote agent's credential; `undefined` for people and the local stdio server. */ +export function agentCredential(principal: Principal): AgentCredential | undefined { + if (principal.actor.kind !== "agent" || principal.local) return undefined; + if (principal.keyId) return { kind: "key", id: principal.keyId }; + if (principal.grantId) return { kind: "grant", id: principal.grantId }; + return undefined; +} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 26ee5f1b..b5a0525d 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -4,6 +4,7 @@ */ export * from "./approvals.ts"; export * from "./audit.ts"; +export * from "./credentials.ts"; export * from "./modes.ts"; export * from "./ownership.ts"; export * from "./policy.ts"; diff --git a/packages/agent-runtime/test/credentials.test.ts b/packages/agent-runtime/test/credentials.test.ts new file mode 100644 index 00000000..2d306c51 --- /dev/null +++ b/packages/agent-runtime/test/credentials.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { agentCredential, type Principal } from "../src/index.ts"; + +const agent = { kind: "agent" as const, id: "agent_cred000001" }; +const human = { kind: "human" as const, id: "user_cred0000001" }; + +describe("agentCredential (RFC-0044 §5 inv. 5)", () => { + it("names a key or an OAuth connection; people and the local server have none", () => { + const p = (extra: Partial): Principal => ({ actor: agent, scopes: [], ...extra }); + expect(agentCredential(p({ keyId: "k1" }))).toEqual({ kind: "key", id: "k1" }); + expect(agentCredential(p({ grantId: "g1" }))).toEqual({ kind: "grant", id: "g1" }); + expect(agentCredential(p({ local: true, keyId: "k1" }))).toBeUndefined(); + expect(agentCredential(p({}))).toBeUndefined(); + expect(agentCredential({ actor: human, scopes: [], grantId: "g1" })).toBeUndefined(); + }); +}); diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index b481497c..07778268 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -66,12 +66,17 @@ export function createAuth(options: AuthOptions): CloudhalAuth { resource: options.mcpOauth.resource, grants: createAgentGrantService(d), // RFC 8414: the server-only `getOAuthServerConfig` endpoint; `createApi` serves it. - metadata: async () => - (await ( - d.instance.api as unknown as { - getOAuthServerConfig(): Promise>; - } - ).getOAuthServerConfig()) ?? {}, + // No DPoP in v1 (ADR-0010): its algorithms are not advertised (the token endpoint + // refuses a proof, `oauth-hooks.ts`). + metadata: async () => { + const { dpop_signing_alg_values_supported: _dpop, ...metadata } = + (await ( + d.instance.api as unknown as { + getOAuthServerConfig(): Promise>; + } + ).getOAuthServerConfig()) ?? {}; + return metadata; + }, createClient: (input) => createOauthClient(d, input), }, } diff --git a/packages/api/src/auth/access-token.ts b/packages/api/src/auth/access-token.ts index 65f03766..921be100 100644 --- a/packages/api/src/auth/access-token.ts +++ b/packages/api/src/auth/access-token.ts @@ -9,7 +9,7 @@ * Worker-safe: `jose` uses WebCrypto. */ import { createLocalJWKSet, decodeProtectedHeader, type JSONWebKeySet, jwtVerify } from "jose"; -import { ACCESS_TOKEN_ALG } from "./oauth.ts"; +import { ACCESS_TOKEN_ALG, GRANT_CLAIM } from "./oauth.ts"; /** A bearer value shaped like an access token: three base64url segments, header `typ: at+jwt`. */ export function looksLikeAccessToken(value: string): boolean { @@ -27,8 +27,9 @@ export function looksLikeAccessToken(value: string): boolean { /** The bearer value of `headers` when it is an access token (`looksLikeAccessToken`). */ export function accessTokenOf(headers: Headers): string | undefined { - const bearer = /^Bearer\s+(\S+)\s*$/i.exec(headers.get("authorization") ?? "")?.[1]; - return bearer && looksLikeAccessToken(bearer) ? bearer : undefined; + const credential = /^(?:Bearer|DPoP)\s+(\S+)\s*$/i.exec(headers.get("authorization") ?? ""); + const value = credential?.[1]; + return value && looksLikeAccessToken(value) ? value : undefined; } export interface VerifiedAccessToken { @@ -36,6 +37,8 @@ export interface VerifiedAccessToken { readonly sub: string; /** The OAuth client (`client_id`, else `azp`). */ readonly clientId: string; + /** The grant the token was issued for (`cloudhal_grant`, RFC-0044 §5). */ + readonly grantId: string; readonly scopes: string[]; readonly jti: string | undefined; readonly exp: number; @@ -96,20 +99,31 @@ export function createAccessTokenVerifier(options: AccessTokenVerifierOptions) { typ: "at+jwt", // The one algorithm the server signs with (`oauthPlugins`): no algorithm confusion. algorithms: [ACCESS_TOKEN_ALG], - requiredClaims: ["sub", "exp", "iat"], + requiredClaims: ["sub", "exp", "iat", GRANT_CLAIM], currentDate: new Date(now()), }); + // No DPoP in v1 (ADR-0010): a sender-constrained token (`cnf`, RFC 9449) is refused rather + // than accepted as a plain bearer token. + if (payload.cnf !== undefined) return undefined; + const grantId = payload[GRANT_CLAIM]; const clientId = typeof payload.client_id === "string" ? payload.client_id : typeof payload.azp === "string" ? payload.azp : undefined; - if (!payload.sub || !clientId || typeof payload.exp !== "number") return undefined; + if ( + !payload.sub || + !clientId || + typeof payload.exp !== "number" || + typeof grantId !== "string" + ) + return undefined; const scope = typeof payload.scope === "string" ? payload.scope : ""; return { sub: payload.sub, clientId, + grantId, scopes: scope.split(" ").filter((s) => s.length > 0), jti: payload.jti, exp: payload.exp, diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts index 9a89c56b..ec68c9f4 100644 --- a/packages/api/src/auth/agent-consent.ts +++ b/packages/api/src/auth/agent-consent.ts @@ -152,6 +152,7 @@ async function forward( body: Record, ): Promise { const origin = new URL(d.endpoints?.issuer ?? "http://invalid").origin; + const address = d.addressHeader ? headers.get(d.addressHeader) : null; const response = await d.instance.handler( new Request(`${d.endpoints?.issuer}/oauth2/consent`, { method: "POST", @@ -161,6 +162,7 @@ async function forward( origin, cookie: headers.get("cookie") ?? "", [SERVER_KEY_HEADER]: d.internal.serverKey, + ...(d.addressHeader && address ? { [d.addressHeader]: address } : {}), }, body: JSON.stringify(body), }), @@ -221,14 +223,11 @@ export async function giveConsent( ...kept.map((s) => s.requested), ...(requested.includes(OFFLINE_ACCESS) ? [OFFLINE_ACCESS] : []), ]; - const redirect = await forward(d, input.headers, { - accept: true, - scope: providerScopes.join(" "), - oauth_query: input.oauthQuery, - }); const now = d.now().toISOString(); - // One batch: the grant, the person behind the agent actor (RFC-0005 §4, as `recordAgent` does - // for a key) and the audit row. + // The grant first: the provider's consent runs its authorize step, whose check + // (`oauth-hooks.ts`) forgets any stored consent the live grant does not cover. One batch: the + // grant, the person behind the agent actor (RFC-0005 §4, as `recordAgent` does for a key) and + // the audit row. A failed provider call after it leaves a grant no token can use yet. const grant = await d.grants.consent( { userId: who.userId, @@ -248,5 +247,10 @@ export async function giveConsent( ]; }, ); + const redirect = await forward(d, input.headers, { + accept: true, + scope: providerScopes.join(" "), + oauth_query: input.oauthQuery, + }); return { redirect, grant }; } diff --git a/packages/api/src/auth/agent-grants.ts b/packages/api/src/auth/agent-grants.ts index f8670e52..295468ed 100644 --- a/packages/api/src/auth/agent-grants.ts +++ b/packages/api/src/auth/agent-grants.ts @@ -15,7 +15,7 @@ import { } from "@cloudhal/agent-runtime"; import { consentInfo, giveConsent } from "./agent-consent.ts"; import type { AuthDeps } from "./deps.ts"; -import { type AgentGrant, agentIdForGrant } from "./grants.ts"; +import { type AgentGrant, agentIdForGrant, providerRevocation } from "./grants.ts"; import { actorIdForUser, roleScopesHold } from "./key-rows.ts"; import { AuthError } from "./types.ts"; @@ -71,7 +71,7 @@ export function createAgentGrantService(d: AuthDeps) { return out; }; - /** Ends a connection: the grant row first (authoritative), then the provider's consent and tokens. */ + /** Ends a connection: the grant row, the provider's consent and refresh tokens, at once. */ const revoke = async (input: { headers: Headers; id: string; @@ -84,9 +84,11 @@ export function createAgentGrantService(d: AuthDeps) { // Someone else's connection is invisible without agents:manage (no existence oracle). if (!grant || grant.revokedAt || (!mine && !manage)) throw new AuthError("unknown connection", { status: 404, code: "not_found" }); - const audit = AuditLog.statement(input.audit(grant)); - await d.grants.revoke(grant.id, [audit]); - await d.revokeProviderGrant(grant.userId, grant.clientId); + // One batch: the grant, the provider's consent and refresh tokens, the audit row. + await d.grants.revoke(grant.id, [ + ...providerRevocation(grant.userId, grant.clientId, d.now().toISOString()), + AuditLog.statement(input.audit(grant)), + ]); return grant; }; diff --git a/packages/api/src/auth/deps.ts b/packages/api/src/auth/deps.ts index 7943d663..2336af25 100644 --- a/packages/api/src/auth/deps.ts +++ b/packages/api/src/auth/deps.ts @@ -17,9 +17,9 @@ export function authDeps(options: AuthOptions) { const staffStore = new StaffStore(options.sql, now); const keyLimit = options.agentKeyRateLimit ?? AGENT_KEY_RATE_LIMIT; // Marks the server's own consent call to the provider (`AgentGrantService.consent`). - const internal = { serverKey: `${newGrantId()}${newGrantId()}` }; - const instance = createInstance(options, staffStore, internal); const grants = new GrantStore(options.sql, now); + const internal = { serverKey: `${newGrantId()}${newGrantId()}`, grants }; + const instance = createInstance(options, staffStore, internal); const api = instance.api as unknown as { getJwks(): Promise<{ keys: JWK[] }>; verifyApiKey(input: { body: { key: string } }): Promise; @@ -58,15 +58,6 @@ export function authDeps(options: AuthOptions) { where: { field: string; value: unknown }[]; }): Promise; delete(input: { model: string; where: { field: string; value: unknown }[] }): Promise; - deleteMany(input: { - model: string; - where: { field: string; value: unknown }[]; - }): Promise; - updateMany(input: { - model: string; - where: { field: string; value: unknown }[]; - update: Record; - }): Promise; }; type AuthContextLike = { adapter: AdapterLike; @@ -127,7 +118,7 @@ export function authDeps(options: AuthOptions) { code: "mode_above_role", }); }; - const oauth = oauthDeps({ options, api, context, grants, staffStore, keyLimit, now }); + const oauth = oauthDeps({ options, api, grants, staffStore, keyLimit, now }); return { now, sql: options.sql, diff --git a/packages/api/src/auth/grants.ts b/packages/api/src/auth/grants.ts index 372aa849..766021c7 100644 --- a/packages/api/src/auth/grants.ts +++ b/packages/api/src/auth/grants.ts @@ -208,6 +208,28 @@ export class GrantStore { } } +/** + * The provider's side of ending connections (§2 "Revocation"): its stored consent, so the next + * authorization shows Studio's screen again, and its refresh tokens, so no new access token is + * minted. For one client, or every client of the member (removal). Plain SQL, so it rides in the + * same atomic batch as the grant change and can never leave a consent without a live grant. + */ +export function providerRevocation( + userId: string, + clientId: string | undefined, + at: string, +): SqlStatement[] { + const client = clientId === undefined ? "" : ' AND "clientId" = ?'; + const scope = clientId === undefined ? [userId] : [userId, clientId]; + return [ + { sql: `DELETE FROM "oauthConsent" WHERE "userId" = ?${client}`, params: scope }, + { + sql: `UPDATE "oauthRefreshToken" SET "revoked" = ? WHERE "userId" = ?${client} AND "revoked" IS NULL`, + params: [at, ...scope], + }, + ]; +} + /** A grant id: 24 base62 characters from WebCrypto. */ export function newGrantId(): string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 3efbb56c..174e9685 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -5,7 +5,9 @@ import { APIError, createAuthMiddleware } from "better-auth/api"; import { admin, organization } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; import type { StaffStore } from "../staff.ts"; +import type { GrantStore } from "./grants.ts"; import { betterAuthLogger, oauthEndpoints, oauthPlugins } from "./oauth.ts"; +import { oauthBeforeHook } from "./oauth-hooks.ts"; import { API_KEY_HEADER, type AuthOptions, @@ -65,12 +67,15 @@ export function createInstance( internal: InternalKeys, ): Auth { const mode = signUpMode(options); + const clock = options.now ?? (() => new Date()); const ipHeader = options.workers ? "cf-connecting-ip" : options.clientAddressHeader; // RFC-0044 §2: the authorization server, when the runtime has a stable public origin. const oauth = options.mcpOauth - ? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, (headers) => - constantTimeEqual(headers.get(SERVER_KEY_HEADER) ?? "", internal.serverKey), - ) + ? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, { + fromServer: (headers) => + constantTimeEqual(headers.get(SERVER_KEY_HEADER) ?? "", internal.serverKey), + grantOf: async (userId, clientId) => (await internal.grants.live(userId, clientId))?.id, + }) : []; const instance = betterAuth({ // biome-ignore lint/suspicious/noExplicitAny: Better Auth accepts several concrete DB handles @@ -105,6 +110,9 @@ export function createInstance( throw new APIError("FORBIDDEN", { message: "consent is given on Studio's screen" }); return; } + // RFC-0044: stale consent memory, DPoP and the refresh family's absolute lifetime. + if (options.mcpOauth && OAUTH_HOOK_PATHS.has(ctx.path)) + return oauthBeforeHook(ctx, { grants: internal.grants, staff, now: clock }); if (!ctx.path.startsWith("/sign-up") || mode !== "first-user") return; const existing = await ctx.context.adapter.count({ model: "user" }); if (existing > 0) @@ -195,8 +203,13 @@ export interface InternalKeys { * forwards, and server-side client registration (`OauthServer.createClient`). */ readonly serverKey: string; + /** The `agent_grants` rows (RFC-0044 §5): the token claim and the authorize checks read them. */ + readonly grants: GrantStore; } +/** Provider endpoints Cloudhal checks first (`oauth-hooks.ts`). */ +const OAUTH_HOOK_PATHS: ReadonlySet = new Set(["/oauth2/authorize", "/oauth2/token"]); + /** The header that carries `InternalKeys.serverKey`. */ export const SERVER_KEY_HEADER = "x-cloudhal-server-key"; diff --git a/packages/api/src/auth/oauth-deps.ts b/packages/api/src/auth/oauth-deps.ts index 60d55fce..bafa2e52 100644 --- a/packages/api/src/auth/oauth-deps.ts +++ b/packages/api/src/auth/oauth-deps.ts @@ -1,7 +1,7 @@ /** * RFC-0044 Phase 2: what `authDeps` adds when the deployment runs the authorization server: the - * issuer and resource, the access-token verifier `/mcp` uses (§5), and the provider-side cleanup - * of a revoked connection (§2 "Revocation"). + * issuer and resource, the access-token verifier `/mcp` uses (§5), and the client-address header + * the consent forward keeps. */ import type { JWK } from "jose"; import type { StaffStore } from "../staff.ts"; @@ -11,19 +11,9 @@ import { oauthEndpoints } from "./oauth.ts"; import type { TokenPrincipalDeps } from "./token-principal.ts"; import type { AuthOptions } from "./types.ts"; -interface AdapterWrites { - deleteMany(input: { model: string; where: { field: string; value: unknown }[] }): Promise; - updateMany(input: { - model: string; - where: { field: string; value: unknown }[]; - update: Record; - }): Promise; -} - export interface OauthDepsInput { readonly options: AuthOptions; readonly api: { getJwks(): Promise<{ keys: JWK[] }> }; - readonly context: () => Promise<{ adapter: AdapterWrites }>; readonly grants: GrantStore; readonly staffStore: StaffStore; readonly keyLimit: { readonly windowMs: number; readonly max: number }; @@ -31,7 +21,7 @@ export interface OauthDepsInput { } export function oauthDeps(input: OauthDepsInput) { - const { options, api, context, grants, staffStore, keyLimit, now } = input; + const { options, api, grants, staffStore, keyLimit, now } = input; const endpoints = options.mcpOauth ? oauthEndpoints(options.baseURL) : undefined; const tokens: TokenPrincipalDeps | undefined = endpoints && options.mcpOauth @@ -48,16 +38,8 @@ export function oauthDeps(input: OauthDepsInput) { now, } : undefined; - /** The provider's consent and refresh tokens for (member, client). */ - const revokeProviderGrant = async (userId: string, clientId: string) => { - if (!endpoints) return; - const { adapter } = await context(); - const where = [ - { field: "clientId", value: clientId }, - { field: "userId", value: userId }, - ]; - await adapter.deleteMany({ model: "oauthConsent", where }); - await adapter.updateMany({ model: "oauthRefreshToken", where, update: { revoked: now() } }); - }; - return { endpoints, tokens, revokeProviderGrant }; + // The runtime's trusted client-address header (as `createInstance` reads it): the consent + // forward carries it, so Better Auth's rate limit counts per client, not in one shared bucket. + const addressHeader = options.workers ? "cf-connecting-ip" : options.clientAddressHeader; + return { endpoints, tokens, addressHeader }; } diff --git a/packages/api/src/auth/oauth-hooks.ts b/packages/api/src/auth/oauth-hooks.ts new file mode 100644 index 00000000..a0548abc --- /dev/null +++ b/packages/api/src/auth/oauth-hooks.ts @@ -0,0 +1,99 @@ +/** + * RFC-0044 Phase 2: what Cloudhal checks before the provider's own endpoints run. + * + * - `/oauth2/authorize`: the provider skips its consent page when a stored consent covers the + * request. That memory must never outlive Cloudhal's grant: without a live `agent_grants` row + * for the member and client, or when the member's current role would give a requested scope + * that the grant does not hold (a promotion, then a step-up), the stored consent is deleted so + * the member sees Studio's consent screen again. + * - `/oauth2/token`: no DPoP in v1 (ADR-0010), so a `DPoP` proof is refused instead of minting a + * sender-constrained token `/mcp` would not check; and a refresh token family ends 90 days after + * the member authenticated for it (§2 "90 d absolute"), however often it was rotated. + */ +import { holds, isKeyScope, narrow, roleScopes } from "@cloudhal/agent-runtime"; +import { APIError, getSessionFromCtx } from "better-auth/api"; +import type { StaffStore } from "../staff.ts"; +import type { GrantStore } from "./grants.ts"; +import { hashToken } from "./oauth.ts"; + +/** §2: a refresh token family's absolute lifetime, from the authentication it began with. */ +export const REFRESH_FAMILY_MAX_MS = 90 * 24 * 60 * 60_000; + +export interface OauthHookDeps { + readonly grants: GrantStore; + readonly staff: StaffStore; + readonly now: () => Date; +} + +type Where = { field: string; value: unknown }[]; +interface HookContext { + path?: string | undefined; + query?: Record | undefined; + body?: Record | undefined; + headers?: Headers | undefined; + context: { + adapter: { + findOne(input: { model: string; where: Where }): Promise; + deleteMany(input: { model: string; where: Where }): Promise; + }; + }; +} + +/** Runs the checks for `ctx.path`; throws the OAuth error the provider would. */ +export async function oauthBeforeHook(ctx: HookContext, d: OauthHookDeps): Promise { + if (ctx.path === "/oauth2/authorize") return forgetStaleConsent(ctx, d); + if (ctx.path === "/oauth2/token") return checkTokenRequest(ctx, d); +} + +/** True when the provider's stored consent may no longer stand in for Studio's screen. */ +export async function consentIsStale( + d: OauthHookDeps, + input: { userId: string; clientId: string; requested: readonly string[] }, +): Promise { + const grant = await d.grants.live(input.userId, input.clientId); + if (!grant) return true; + const member = await d.staff.member(input.userId); + if (!member) return true; + const held = roleScopes(member.role); + return input.requested.filter(isKeyScope).some((scope) => { + const given = narrow([scope], held)[0]; + return given !== undefined && !holds(grant.scopes, given); + }); +} + +async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise { + const clientId = ctx.query?.client_id; + if (typeof clientId !== "string" || !clientId) return; + const session = await getSessionFromCtx(ctx as never).catch(() => null); + if (!session) return; + const scope = typeof ctx.query?.scope === "string" ? ctx.query.scope : ""; + const requested = scope.split(" ").filter(Boolean); + if (!(await consentIsStale(d, { userId: session.user.id, clientId, requested }))) return; + await ctx.context.adapter.deleteMany({ + model: "oauthConsent", + where: [ + { field: "clientId", value: clientId }, + { field: "userId", value: session.user.id }, + ], + }); +} + +async function checkTokenRequest(ctx: HookContext, d: OauthHookDeps): Promise { + if (ctx.headers?.get("dpop")) + throw new APIError("BAD_REQUEST", { + error: "invalid_dpop_proof", + error_description: "DPoP is not supported by this server; use a bearer token", + }); + const body = ctx.body ?? {}; + if (body.grant_type !== "refresh_token" || typeof body.refresh_token !== "string") return; + const row = await ctx.context.adapter.findOne<{ authTime?: Date | string | number | null }>({ + model: "oauthRefreshToken", + where: [{ field: "token", value: await hashToken(body.refresh_token) }], + }); + const began = row?.authTime == null ? Number.NaN : new Date(row.authTime).getTime(); + if (!Number.isNaN(began) && d.now().getTime() - began > REFRESH_FAMILY_MAX_MS) + throw new APIError("BAD_REQUEST", { + error: "invalid_grant", + error_description: "this connection is older than 90 days; authorize it again", + }); +} diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts index 420bfc25..698687ff 100644 --- a/packages/api/src/auth/oauth.ts +++ b/packages/api/src/auth/oauth.ts @@ -86,9 +86,9 @@ export function oauthEndpoints(baseURL: string): { issuer: string; resource: str export function oauthPlugins( issuer: string, options: McpOauthServerOptions, - /** True for the server's own calls (`SERVER_KEY_HEADER`): only they may create clients. */ - fromServer: (headers: Headers) => boolean, + hooks: OauthServerHooks, ): BetterAuthPlugin[] { + const { fromServer, grantOf } = hooks; return [ jwt({ jwks: { keyPairConfig: { alg: ACCESS_TOKEN_ALG, crv: "Ed25519" } }, @@ -116,10 +116,47 @@ export function oauthPlugins( allowUnauthenticatedClientRegistration: false, clientPrivileges: ({ headers, action }) => action === "create" && fromServer(headers), resourcePrivileges: ({ headers, action }) => action === "link" && fromServer(headers), + // Tokens are stored under a hash Cloudhal can compute too, so the refresh path can find a + // token's family and enforce the absolute lifetime (`oauth-hooks.ts`). + storeTokens: { hash: (token) => hashToken(token) }, + extensions: [ + { + // The grant an access token belongs to (§5): `/mcp` compares it with the live + // `agent_grants` row, so a token issued before a revoke never works for a later grant + // of the same member and client. No live grant, no claim: `/mcp` refuses the token. + claims: { + accessToken: async ({ user, client }) => { + const grant = user ? await grantOf(user.id, client.clientId) : undefined; + return grant ? { [GRANT_CLAIM]: grant } : {}; + }, + }, + }, + ], }), ]; } +/** What `oauthPlugins` asks of Cloudhal. */ +export interface OauthServerHooks { + /** True for the server's own calls (`SERVER_KEY_HEADER`): only they may create clients. */ + readonly fromServer: (headers: Headers) => boolean; + /** The id of the member's live grant for a client, if any. */ + readonly grantOf: (userId: string, clientId: string) => Promise; +} + +/** The access-token claim that names the grant (RFC-0044 §5; a private claim name, RFC 7519 §4.3). */ +export const GRANT_CLAIM = "cloudhal_grant"; + +/** How the provider stores codes and refresh tokens: base64url(SHA-256(token)). */ +export async function hashToken(token: string): Promise { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)), + ); + let s = ""; + for (const b of digest) s += String.fromCharCode(b); + return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + /** A log line from Better Auth, routed through Cloudhal's logging (`AuthOptions.log`). */ export type AuthLog = (level: "error" | "warn" | "info" | "debug", message: string) => void; diff --git a/packages/api/src/auth/token-principal.ts b/packages/api/src/auth/token-principal.ts index 1cb085bb..cce82bfd 100644 --- a/packages/api/src/auth/token-principal.ts +++ b/packages/api/src/auth/token-principal.ts @@ -44,7 +44,9 @@ export async function tokenPrincipal( const claims = await d.verify(token); if (!claims) return undefined; const grant = await d.grants.live(claims.sub, claims.clientId); - if (!grant) return undefined; // inv. 4: revoked, or consent never went through Studio + // inv. 4: revoked, consent never went through Studio, or the token belongs to an earlier + // grant of the same member and client (revoked, then connected again). + if (!grant || grant.id !== claims.grantId) return undefined; // The same fixed window as a key (`rate_limits`), per connection. if (!(await d.staffStore.hit(`cloudhal:grant:${grant.id}`, d.keyLimit.windowMs, d.keyLimit.max))) throw new HTTPException(429, { diff --git a/packages/api/src/staff.ts b/packages/api/src/staff.ts index 81eb56a7..123dc51a 100644 --- a/packages/api/src/staff.ts +++ b/packages/api/src/staff.ts @@ -9,6 +9,7 @@ */ import { isRole, type Role } from "@cloudhal/agent-runtime"; import type { SqlDriver } from "@cloudhal/storage"; +import { providerRevocation } from "./auth/grants.ts"; import { countHit, pruneCounters, randomPrune } from "./rate-limits.ts"; import { allSiteRoles, @@ -311,13 +312,19 @@ export class StaffStore { return rows.map((r) => r.agent_id); } - /** Revokes every OAuth connection of a person (removal; RFC-0044 §5 inv. 4); kept for audit. */ - async revokeGrantsOf(userId: string): Promise { - const r = await this.#sql.run( - "UPDATE agent_grants SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", - [this.#stamp(), userId], - ); - return r.changes; + /** + * Ends every OAuth connection of a person (removal; RFC-0044 §5 inv. 4): their grants (kept for + * audit), and on the provider's side their stored consents and refresh tokens, in one batch. + */ + async revokeGrantsOf(userId: string): Promise { + const at = this.#stamp(); + await this.#sql.batch([ + { + sql: "UPDATE agent_grants SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + params: [at, userId], + }, + ...providerRevocation(userId, undefined, at), + ]); } /** Disables every key of a person (removal, RFC-0005 §3); kept for audit, deletable. */ diff --git a/packages/api/src/testing-mcp-oauth-hardening.ts b/packages/api/src/testing-mcp-oauth-hardening.ts new file mode 100644 index 00000000..f254b322 --- /dev/null +++ b/packages/api/src/testing-mcp-oauth-hardening.ts @@ -0,0 +1,174 @@ +/** + * Shared suite for the review of RFC-0044 Phase 2 (PR #176): what a connection may never keep + * after it ends, and what the provider must not do on its own. Runs next to + * `testing-mcp-oauth-server.ts` on better-sqlite3 and Miniflare D1. + */ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { + authorize, + connect, + consentRequest, + type Json, + mcpCall, + mcpClient, + ORIGIN, + setupServer, + token, +} from "./testing-oauth-harness.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +const READ = "site:read content:read"; +const DAY = 24 * 60 * 60_000; + +export function describeMcpOauthHardening(label: string, backend: RolesBackend): void { + describe(`MCP OAuth hardening: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + afterEach(() => { + vi.useRealTimers(); + clearAccessTokenKeyCache(); + }); + + const grantsOf = async (s: Awaited>, cookie: string) => + (await s.call("GET", "/api/agent-grants", { cookie })).body as unknown as Json[]; + + it("keeps a token issued before a revoke dead after the member connects the client again", async () => { + const s = await setupServer(backend); + const old = await connect(s, { cookie: s.owner.cookie, scope: READ }); + const [grant] = await grantsOf(s, s.owner.cookie); + const revoked = await s.call("DELETE", `/api/agent-grants/${grant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + expect(revoked.status).toBe(204); + const fresh = await connect(s, { cookie: s.owner.cookie, scope: READ }); + const stale = await s.app.request(`${ORIGIN}/mcp`, mcpCall(old.access, "tools/list")); + expect(stale.status).toBe(401); + expect(stale.headers.get("www-authenticate")).toContain('error="invalid_token"'); + await (await mcpClient(s.app, fresh.access)).listTools(); + }); + + it("ends a removed member's refresh tokens and stored consent, so a return shows the screen", async () => { + const s = await setupServer(backend); + const editor = await s.join("editor"); + const { refresh } = await connect(s, { + cookie: editor.cookie, + scope: `${READ} offline_access`, + }); + const removed = await s.call("DELETE", `/api/members/${editor.userId}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + expect(removed.status, JSON.stringify(removed.body)).toBe(200); + const again = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(again.body.error).toBe("invalid_grant"); + // Invited back with the same account: the provider must not skip Studio's screen. + const back = await s.join("editor", {}, editor.email); + const start = await authorize(s, { cookie: back.cookie, scope: READ }); + expect(start.location?.pathname, start.location?.href).toBe("/oauth/consent"); + }); + + it("shows the screen again when a promotion lets a step-up widen the connection", async () => { + const s = await setupServer(backend); + const member = await s.join("contributor"); + const scope = `${READ} content:write`; + await connect(s, { cookie: member.cookie, scope }); + expect((await grantsOf(s, member.cookie))[0]?.scopes).toContain("content:draft:own"); + const promoted = await s.call("PATCH", `/api/members/${member.userId}`, { + cookie: s.owner.cookie, + body: { role: "editor" }, + }); + expect(promoted.status, JSON.stringify(promoted.body)).toBe(200); + const start = await authorize(s, { cookie: member.cookie, scope }); + expect(start.location?.pathname, start.location?.href).toBe("/oauth/consent"); + await connect(s, { cookie: member.cookie, scope }); + expect((await grantsOf(s, member.cookie))[0]?.scopes).toContain("content:write"); + }); + + it("rate-limits the consent forward per client address, not in one shared bucket", async () => { + const s = await setupServer(backend, { clientAddressHeader: "x-test-client" }); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const approved = await s.call("POST", "/api/agent-grants/consent", { + cookie: s.owner.cookie, + headers: { "x-test-client": "203.0.113.9" }, + body: { + request: consentRequest(start.location as URL), + accept: true, + scopes: READ.split(" "), + mode: "edits", + }, + }); + expect(approved.status, JSON.stringify(approved.body)).toBe(200); + const keys = ( + await s.sql.all<{ key: string }>(`SELECT "key" FROM "rateLimit" WHERE "key" LIKE ?`, [ + "%/oauth2/consent", + ]) + ).map((r) => r.key); + expect(keys).toEqual(["203.0.113.9|/oauth2/consent"]); + }); + + it("neither advertises nor issues DPoP-bound tokens (no DPoP in v1)", async () => { + const s = await setupServer(backend); + const meta = (await ( + await s.app.request(`${ORIGIN}/.well-known/oauth-authorization-server/api/auth`) + ).json()) as Json; + expect(meta.dpop_signing_alg_values_supported).toBeUndefined(); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const approved = await s.call("POST", "/api/agent-grants/consent", { + cookie: s.owner.cookie, + body: { + request: consentRequest(start.location as URL), + accept: true, + scopes: READ.split(" "), + mode: "edits", + }, + }); + const code = new URL(approved.body.redirect as string).searchParams.get("code") ?? ""; + const res = await s.app.request(`${ORIGIN}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: "eyJhbGciOiJFUzI1NiJ9.e30.sig", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: s.clientId, + redirect_uri: "http://127.0.0.1:33418/callback", + code_verifier: start.verifier, + resource: `${ORIGIN}/mcp`, + }), + }); + expect(res.status).toBe(400); + expect(((await res.json()) as Json).error).toBe("invalid_dpop_proof"); + }); + + it("ends a refresh token family 90 days after the member authenticated, however often it rotated", async () => { + const s = await setupServer(backend); + let { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + const start = Date.now(); + vi.useFakeTimers({ toFake: ["Date"] }); + for (const day of [29, 58, 87]) { + vi.setSystemTime(start + day * DAY); + const r = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); + expect(r.status, `day ${day}: ${JSON.stringify(r.body)}`).toBe(200); + refresh = r.body.refresh_token as string; + } + vi.setSystemTime(start + 92 * DAY); + const late = await token(s, { + grant_type: "refresh_token", + refresh_token: refresh as string, + }); + expect(late.status).toBe(400); + expect(late.body.error).toBe("invalid_grant"); + }); + }); +} diff --git a/packages/api/src/testing-oauth-harness.ts b/packages/api/src/testing-oauth-harness.ts index aa029f5e..c0109436 100644 --- a/packages/api/src/testing-oauth-harness.ts +++ b/packages/api/src/testing-oauth-harness.ts @@ -27,6 +27,8 @@ export interface DeploymentOptions { readonly baseURL?: string; /** The `/mcp` resource tokens are bound to. */ readonly resource?: string; + /** The trusted client-address header (`AuthOptions.clientAddressHeader`; turns rate limits on). */ + readonly clientAddressHeader?: string; } export type Deployment = Awaited>; @@ -94,6 +96,7 @@ export async function deploy( secret: SECRET, signUp: "first-user", mcpOauth: { resource: options.resource ?? `${new URL(baseURL).origin}/mcp` }, + ...(options.clientAddressHeader ? { clientAddressHeader: options.clientAddressHeader } : {}), }); const server = auth.oauth; if (!server) throw new Error("the authorization server did not start"); @@ -141,10 +144,10 @@ export async function deploy( } /** A fresh database with the Owner signed up, a site and the test client. */ -export async function setupServer(backend: RolesBackend) { +export async function setupServer(backend: RolesBackend, options: DeploymentOptions = {}) { const b = await backend.create(); const site = await seedStarterSite(b.store, { name: "Blog", slug: "blog" }); - const d = await deploy(b); + const d = await deploy(b, options); const signUp = await d.call("POST", "/api/auth/sign-up/email", { body: { name: "Olive Owner", email: "owner@example.com", password: PASSWORD }, }); @@ -156,8 +159,9 @@ export async function setupServer(backend: RolesBackend) { redirectUris: [REDIRECT], }); /** A member who joined through an invite (RFC-0005 §7), signed in. */ - const join = async (role: string, extra: Json = {}) => { - const email = `${role}-${Math.random().toString(36).slice(2, 8)}@example.com`; + /** `email` again: a removed member invited back keeps their account (RFC-0005 §7.4). */ + const join = async (role: string, extra: Json = {}, again?: string) => { + const email = again ?? `${role}-${Math.random().toString(36).slice(2, 8)}@example.com`; const inv = await d.call("POST", "/api/invites", { cookie: owner.cookie, body: { email, role, ...extra }, @@ -169,6 +173,7 @@ export async function setupServer(backend: RolesBackend) { }); expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); return { + email, cookie: cookieOf(accepted.res), userId: (accepted.body.user as Json).id as string, }; diff --git a/packages/api/test/access-token.test.ts b/packages/api/test/access-token.test.ts new file mode 100644 index 00000000..769b6e4a --- /dev/null +++ b/packages/api/test/access-token.test.ts @@ -0,0 +1,58 @@ +/** + * RFC-0044 §2, §5: the `/mcp` verifier against a key of the test's own. No DPoP in v1 (a token + * bound with `cnf` is refused, not taken as a bearer token), and a token without the grant claim + * is not one of ours. + */ +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { describe, expect, it } from "vitest"; +import { + accessTokenOf, + clearAccessTokenKeyCache, + createAccessTokenVerifier, +} from "../src/auth/access-token.ts"; +import { GRANT_CLAIM } from "../src/auth/oauth.ts"; + +const ISSUER = "http://localhost:8787/api/auth"; +const AUDIENCE = "http://localhost:8787/mcp"; + +async function setup() { + clearAccessTokenKeyCache(); // keys are cached per issuer; each test signs with its own + const { publicKey, privateKey } = await generateKeyPair("EdDSA", { crv: "Ed25519" }); + const jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "EdDSA" }; + const verify = createAccessTokenVerifier({ + issuer: ISSUER, + audience: AUDIENCE, + jwks: async () => ({ keys: [jwk] }), + }); + const sign = (claims: Record) => + new SignJWT({ scope: "site:read", client_id: "c1", ...claims }) + .setProtectedHeader({ alg: "EdDSA", typ: "at+jwt", kid: "k1" }) + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setSubject("u1") + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); + return { verify, sign }; +} + +describe("access-token verifier", () => { + it("accepts a bearer token naming its grant, and refuses a DPoP-bound one or one without a grant", async () => { + const { verify, sign } = await setup(); + expect(await verify(await sign({ [GRANT_CLAIM]: "g1" }))).toMatchObject({ + sub: "u1", + clientId: "c1", + grantId: "g1", + scopes: ["site:read"], + }); + expect(await verify(await sign({ [GRANT_CLAIM]: "g1", cnf: { jkt: "x" } }))).toBeUndefined(); + expect(await verify(await sign({}))).toBeUndefined(); + }); + + it("takes a JWT under the DPoP scheme as a token (refused later), never as an agent key", async () => { + const { sign } = await setup(); + const jwt = await sign({ [GRANT_CLAIM]: "g1" }); + expect(accessTokenOf(new Headers({ authorization: `DPoP ${jwt}` }))).toBe(jwt); + expect(accessTokenOf(new Headers({ authorization: "Bearer opaque-key" }))).toBeUndefined(); + }); +}); diff --git a/packages/api/test/mcp-oauth-server.test.ts b/packages/api/test/mcp-oauth-server.test.ts index d85e6f56..aae11e8b 100644 --- a/packages/api/test/mcp-oauth-server.test.ts +++ b/packages/api/test/mcp-oauth-server.test.ts @@ -4,9 +4,10 @@ import { D1Driver } from "@cloudhal/storage/d1"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; import Database from "better-sqlite3"; +import { describeMcpOauthHardening } from "../src/testing-mcp-oauth-hardening.ts"; import { describeMcpOauthServer } from "../src/testing-mcp-oauth-server.ts"; -describeMcpOauthServer("better-sqlite3", { +const sqlite = { async create() { const database = new Database(":memory:"); const sql = new BetterSqlite3Driver(database); @@ -15,11 +16,13 @@ describeMcpOauthServer("better-sqlite3", { await store.init(); return { store, sql, blobs, database }; }, -}); +}; +describeMcpOauthServer("better-sqlite3", sqlite); +describeMcpOauthHardening("better-sqlite3", sqlite); let d1: Awaited> | undefined; const reset = createTableReset(); -describeMcpOauthServer("Cloudflare D1 (Miniflare)", { +const miniflare = { async create() { d1 ??= await createMiniflareD1(); const sql = new D1Driver(d1.db); @@ -28,6 +31,11 @@ describeMcpOauthServer("Cloudflare D1 (Miniflare)", { await reset(sql, () => store.init()); return { store, sql, blobs, database: d1.db }; }, +}; +// One Miniflare for both suites (the table reset keeps its schema); closed after the second. +describeMcpOauthServer("Cloudflare D1 (Miniflare)", miniflare); +describeMcpOauthHardening("Cloudflare D1 (Miniflare)", { + ...miniflare, async dispose() { await d1?.dispose(); }, diff --git a/packages/mcp-server/src/elevation-tools.ts b/packages/mcp-server/src/elevation-tools.ts index fbd5edf1..42be7528 100644 --- a/packages/mcp-server/src/elevation-tools.ts +++ b/packages/mcp-server/src/elevation-tools.ts @@ -6,6 +6,7 @@ import { type AgentMode, type Approval, + agentCredential, ELEVATION_MAX_MINUTES, ELEVATION_MIN_MINUTES, ELEVATION_NOTICE_MINUTES, @@ -80,7 +81,9 @@ function cannotRequest(ctx: ToolContext): string | undefined { return "an automation's key works in the mode its automation allows; ask a person to change the automation"; if (ctx.principal.local) return "this is a local server: restart `cloudhal mcp` with the --mode you need"; - if (!ctx.principal.keyId) return "elevations apply to API keys; this principal has none"; + // An OAuth connection (RFC-0044) has no elevations of its own yet (Phase 3). + if (agentCredential(ctx.principal)?.kind !== "key") + return "elevations apply to API keys; this principal has none"; if (!ctx.approvals) return "this server has no approvals, so nobody could grant it"; return undefined; } diff --git a/packages/mcp-server/src/gate-approval.ts b/packages/mcp-server/src/gate-approval.ts new file mode 100644 index 00000000..2debb696 --- /dev/null +++ b/packages/mcp-server/src/gate-approval.ts @@ -0,0 +1,31 @@ +/** + * The approval a gate change (RFC-0008 §6.4) carries when no request is needed: the person's, or + * an agent key's `auto` approval for a reduction. An OAuth connection (RFC-0044) is an agent too, + * never the person behind it, and has no `auto` approval of its own yet, so its gate change waits + * for a person. + */ +import { agentCredential } from "@cloudhal/agent-runtime"; +import type { Release } from "@cloudhal/core-contracts"; +import { canonicalInstant } from "@cloudhal/schedules"; +import type { ToolContext } from "./common.ts"; +import { personApproval } from "./schedules.ts"; + +/** What `set_embargo` answers an OAuth connection on live content. */ +export const NO_GRANT_GATES = + "The embargo is saved, but an OAuth connection cannot put it live on its own: the content stays visible until a person publishes it (or an agent key sets the embargo)."; + +/** The approval, or `undefined` when the caller is an OAuth connection (it waits for a person). */ +export function directApproval(ctx: ToolContext, at: string): Release["approval"] | undefined { + const credential = agentCredential(ctx.principal); + if (credential && !ctx.confirmed) { + if (credential.kind !== "key") return undefined; + return { + kind: "auto", + by: ctx.principal.actor, + keyId: credential.id, + at, + windowEnds: canonicalInstant(Date.parse(at) + 24 * 3_600_000), + }; + } + return personApproval(ctx.principal, at); +} diff --git a/packages/mcp-server/src/schedule-tools.ts b/packages/mcp-server/src/schedule-tools.ts index a9f56cbb..91adb5e6 100644 --- a/packages/mcp-server/src/schedule-tools.ts +++ b/packages/mcp-server/src/schedule-tools.ts @@ -62,6 +62,7 @@ import { type ToolDefinition, ToolError, } from "./common.ts"; +import { directApproval, NO_GRANT_GATES } from "./gate-approval.ts"; import { approvalProblem, autoReleaseVerdict, @@ -521,19 +522,6 @@ async function gateRelease( return scheduleWith(ctx, rec, { kind: "on-approval" }, approval); } -/** The approval a gate change carries when no request is needed: the person's, or the key's (reduce). */ -function directApproval(ctx: ToolContext, at: string): Release["approval"] { - if (isAgent(ctx) && ctx.principal.keyId && !ctx.principal.local && !ctx.confirmed) - return { - kind: "auto", - by: ctx.principal.actor, - keyId: ctx.principal.keyId, - at, - windowEnds: canonicalInstant(Date.parse(at) + 24 * 3_600_000), - }; - return personApproval(ctx.principal, at); -} - // ───────────────────────────── runs (§11.2) ───────────────────────────── /** The run envelope `begin_run` answers with (§11.2). */ @@ -1071,14 +1059,9 @@ export function scheduleTools(): ToolDefinition[] { message: "This content has other unpublished edits, so the embargo cannot go live on its own: it is still visible until it is published. Ask a person to publish (or schedule) it.", }; - const release = await gateRelease( - ctx, - site, - node.id, - "set_embargo", - directApproval(ctx, at), - true, - ); + const approval = directApproval(ctx, at); + if (!approval) return { node: saved, status: "with-next-publish", message: NO_GRANT_GATES }; + const release = await gateRelease(ctx, site, node.id, "set_embargo", approval, true); return { node: saved, release, status: "scheduled" }; }, }, diff --git a/packages/mcp-server/test/schedules.test.ts b/packages/mcp-server/test/schedules.test.ts index 7cec1a5e..e75401a4 100644 --- a/packages/mcp-server/test/schedules.test.ts +++ b/packages/mcp-server/test/schedules.test.ts @@ -489,6 +489,30 @@ describe("gates over MCP (RFC-0008 §7.6, §10.8)", () => { expect(earlier.text).toMatch(/lift_embargo/); }); + it("set_embargo through an OAuth connection never records the person's approval (RFC-0044 inv. 5)", async () => { + const live = (await s.store.getContentEntry(s.entry.id)) as ContentEntry; + // A token principal: an agent with a grant, no key, and the member behind it. + const { keyId: _key, ...noKey } = agentKey("edits"); + const { call } = await connect(s, { + ...noKey, + grantId: "grant_sched000001", + person: { id: "user_sched00001", userId: "sched00001", role: "owner", name: "Halil" }, + }); + const hide = await call("set_embargo", { + siteId: s.site.id, + nodeId: live.id, + from: plus(7 * HOUR), + expectedRev: live.rev, + }); + expect(hide.isError, hide.text).toBe(false); + expect(hide.data?.status).toBe("with-next-publish"); + expect(hide.data?.release).toBeUndefined(); + const releases = await s.schedules.listReleases(s.site.id); + expect( + releases.some((r) => r.approval?.kind === "person" && r.approval.by.id === "user_sched00001"), + ).toBe(false); + }); + it("lift_embargo always waits for a person, with the reason (D8)", async () => { const draft = await s.store.createContentEntry({ siteId: s.site.id, diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 3ca9e831..44a8d92f 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -25,7 +25,7 @@ "packages/core-ts/src/surface-interop.ts": 1233, "packages/core-ts/src/taxonomy.ts": 1718, "packages/mcp-server/src/menu-tools.ts": 1058, - "packages/mcp-server/src/schedule-tools.ts": 1596, + "packages/mcp-server/src/schedule-tools.ts": 1579, "packages/mcp-server/src/schedules.ts": 833, "packages/mcp-server/src/taxonomy-tools.ts": 1433, "packages/schedules/src/recurrence.ts": 939, @@ -505,7 +505,7 @@ "params": 5 }, "packages/mcp-server/src/schedule-tools.ts::scheduleTools": { - "lines": 1017 + "lines": 1012 }, "packages/mcp-server/src/schedules.ts::autoReleaseVerdict": { "params": 6 From b5968e1a213165d5b0e1fa121849d2ea37992274 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:15:23 +0300 Subject: [PATCH 3/5] fix(api): bind refresh families and codes to the grant (#144) Second verification round of RFC-0044 phase 2 (PR #176): - A refresh token is refused when its family (every refresh token of one authorization code) began before the live grant, and 90 days after the family's first token; the cap no longer follows the Studio session's sliding authTime. - Ending a connection also deletes the member's pending authorization codes for the client. - Member removal revokes grants and the provider side in the same batch as the membership delete, guarded on it. - The authorize check reads a POST authorize's form body. - The provider's issuer-appended metadata (/api/auth/.well-known/*), which listed DPoP, is fenced; the DPoP test asserts Cloudhal's own refusal. - agentCredential() for the auto release key; giveConsent comment. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/adr/0045-mcp-authorization-server.md | 4 +- packages/api/src/auth/agent-consent.ts | 4 +- packages/api/src/auth/grants.ts | 15 ++- packages/api/src/auth/instance.ts | 2 +- packages/api/src/auth/oauth-hooks.ts | 68 ++++++++--- packages/api/src/oauth-server.ts | 3 + packages/api/src/routes/members.ts | 4 +- packages/api/src/staff.ts | 35 +++--- .../api/src/testing-mcp-oauth-hardening.ts | 108 +++++++++++++++++- packages/api/src/testing-oauth-harness.ts | 19 ++- packages/mcp-server/src/gate-approval.ts | 10 +- packages/mcp-server/src/schedule-tools.ts | 4 +- 12 files changed, 226 insertions(+), 50 deletions(-) diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index f053b1e8..f32230bd 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -9,8 +9,8 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve ## Decision 1. **Better Auth's `mcp()` plugin plus `jwt()`** in `createInstance` (`packages/api/src/auth/oauth.ts`), only when the runtime passes `AuthOptions.mcpOauth`. The issuer is Better Auth's base URL on the public origin, `/api/auth`; the resource is `/mcp`. Grants: authorization code with PKCE (S256 only) and refresh token; scopes are key scopes plus `offline_access`, no `openid`. The AS metadata is served by `createApi` at `/.well-known/oauth-authorization-server/api/auth` and at the root alias (Decision 6). -2. **Tokens:** RFC 9068 JWT access tokens (`typ: at+jwt`, EdDSA/Ed25519, 1 h), `aud` = the resource, `iss` = the issuer, and a private claim `cloudhal_grant` naming the grant they were issued for (a provider extension adds it from the live `agent_grants` row); refresh tokens for 30 d sliding, rotated on every use with `mcp()`'s 30 s reuse window (Decision 2), and refused 90 d after the authentication the family began with (`oauth-hooks.ts`, §2 "90 d absolute"); authorization codes 5 min. `/mcp` verifies signature, `iss`, `aud`, `exp`, `typ` and the grant claim with `jose` against the `jwks` table read through the instance (cached per isolate by `kid`, never fetched over HTTP), pinned to EdDSA. **No DPoP in v1:** the token endpoint refuses a `DPoP` proof, the metadata does not advertise DPoP algorithms, and `/mcp` refuses any token carrying `cnf`. -3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row, a row of a later grant than the token's `cloudhal_grant` (revoked, then connected again) or no membership is a 401 with the `invalid_token` challenge. Ending a connection (Studio's revoke, or removing the member) revokes the grant, deletes the provider's stored consent and revokes its refresh tokens in one SQL batch. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged. +2. **Tokens:** RFC 9068 JWT access tokens (`typ: at+jwt`, EdDSA/Ed25519, 1 h), `aud` = the resource, `iss` = the issuer, and a private claim `cloudhal_grant` naming the grant they were issued for (a provider extension adds it from the live `agent_grants` row); refresh tokens for 30 d sliding, rotated on every use with `mcp()`'s 30 s reuse window (Decision 2), and refused 90 d after the family's first refresh token (`oauth-hooks.ts`, §2 "90 d absolute"; a family is every refresh token of one authorization code, never anchored to the Studio session, which slides) or when the family began before the live grant (a revoke, then a reconnect); authorization codes 5 min. `/mcp` verifies signature, `iss`, `aud`, `exp`, `typ` and the grant claim with `jose` against the `jwks` table read through the instance (cached per isolate by `kid`, never fetched over HTTP), pinned to EdDSA. **No DPoP in v1:** a token request with a `DPoP` proof gets a 400 (`invalid_dpop_proof`) rather than a fallback to Bearer, the metadata does not advertise DPoP algorithms (the provider's own issuer-appended document under `/api/auth/.well-known/` is not served), and `/mcp` refuses any token carrying `cnf`. +3. **One grant row per member and client** (`agent_grants`, migration `0031_oauth`): the scopes after narrowing at consent, the mode, an elevation, `last_used_at` (written at most every 5 min) and the revocation. Every token call reads it; no row, a revoked row, a row of a later grant than the token's `cloudhal_grant` (revoked, then connected again) or no membership is a 401 with the `invalid_token` challenge. Ending a connection (Studio's revoke, or removing the member, where it shares the membership's batch) revokes the grant, deletes the provider's stored consent and pending authorization codes and revokes its refresh tokens in one SQL batch. The principal is `narrow(narrow(token.scope, grant.scopes), roleScopes(role))` with the mode clamped like a key's, `grantId`, `keyScopes` and `person` — the same `Principal` shape a key produces, so policy, audit, approvals, modes, elevations and `forSite` are unchanged. 4. **Consent is Cloudhal's.** The provider's `consentPage` is Studio's `/oauth/consent`; the screen posts to `/api/agent-grants/consent`, which verifies the signed request, narrows the requested scopes by the member's **installation** role (the cap #173's step-up relies on), caps the mode, calls the provider's consent endpoint with the scopes kept (a subset of the request, as the provider requires), writing the grant, `agent_owner` and the `connect_agent` audit row in one batch before it calls the provider. Before the provider's authorize step, Cloudhal deletes the provider's stored consent for the member and client when no live grant exists, or when the member's current role would give a requested scope the grant does not hold (a promotion followed by a step-up), so the provider's consent memory (Decision 8) never skips Studio's screen when the grant would change. Agent code that gives a key something of its own (an `auto` release approval, an elevation, an automation run) asks `agentCredential()` and never treats a connection as the person behind it. The provider's own `/oauth2/consent` refuses HTTP callers (a per-instance server key) and is fenced in `createApi` with every other provider endpoint that is not authorize, token or revoke (client and consent CRUD, introspection, userinfo, registration, the `jwt()` plugin's `/token`). 5. **No authorization server without a stable origin** (`mcpOauthFor`): the admin Worker uses the request's origin (as Better Auth's `baseURL` already does); `cloudhal studio` needs `--base-url` / `CLOUDHAL_BASE_URL` and logs why OAuth is off otherwise; plain HTTP is accepted on loopback only; `CLOUDHAL_MCP_OAUTH=off` turns it off on both. The playground stays out (Decision 10). 6. **Registration is Phase 3.** Phase 2 has no CIMD and no DCR; `OauthServer.createClient` (server-side only) creates a pre-registered public client for tests, the provider's `clientPrivileges` admitting only the server's own calls. diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts index ec68c9f4..94bd2d98 100644 --- a/packages/api/src/auth/agent-consent.ts +++ b/packages/api/src/auth/agent-consent.ts @@ -227,7 +227,9 @@ export async function giveConsent( // The grant first: the provider's consent runs its authorize step, whose check // (`oauth-hooks.ts`) forgets any stored consent the live grant does not cover. One batch: the // grant, the person behind the agent actor (RFC-0005 §4, as `recordAgent` does for a key) and - // the audit row. A failed provider call after it leaves a grant no token can use yet. + // the audit row. If the provider call then fails: a new grant has no token yet; on a step-up of + // a live grant, the scopes and mode the member approved already apply to the connection's + // existing tokens, which their own `scope` claims still cap (no scope they were not issued). const grant = await d.grants.consent( { userId: who.userId, diff --git a/packages/api/src/auth/grants.ts b/packages/api/src/auth/grants.ts index 766021c7..f193fa88 100644 --- a/packages/api/src/auth/grants.ts +++ b/packages/api/src/auth/grants.ts @@ -210,9 +210,10 @@ export class GrantStore { /** * The provider's side of ending connections (§2 "Revocation"): its stored consent, so the next - * authorization shows Studio's screen again, and its refresh tokens, so no new access token is - * minted. For one client, or every client of the member (removal). Plain SQL, so it rides in the - * same atomic batch as the grant change and can never leave a consent without a live grant. + * authorization shows Studio's screen again; its refresh tokens, so no new access token is + * minted; and its pending authorization codes (5 min), so none is exchanged after a reconnect. For + * one client, or every client of the member (removal). Plain SQL, so it rides in the same atomic + * batch as the grant change and can never leave a consent without a live grant. */ export function providerRevocation( userId: string, @@ -221,12 +222,20 @@ export function providerRevocation( ): SqlStatement[] { const client = clientId === undefined ? "" : ' AND "clientId" = ?'; const scope = clientId === undefined ? [userId] : [userId, clientId]; + // Codes are verification rows whose value is the provider's JSON; other rows may not be JSON. + const code = (path: string) => + `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; + const codeClient = clientId === undefined ? "" : ` AND ${code("$.query.client_id")} = ?`; return [ { sql: `DELETE FROM "oauthConsent" WHERE "userId" = ?${client}`, params: scope }, { sql: `UPDATE "oauthRefreshToken" SET "revoked" = ? WHERE "userId" = ?${client} AND "revoked" IS NULL`, params: [at, ...scope], }, + { + sql: `DELETE FROM "verification" WHERE "value" LIKE '%authorization_code%' AND ${code("$.type")} = 'authorization_code' AND ${code("$.userId")} = ?${codeClient}`, + params: scope, + }, ]; } diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 174e9685..8ebf91f3 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -112,7 +112,7 @@ export function createInstance( } // RFC-0044: stale consent memory, DPoP and the refresh family's absolute lifetime. if (options.mcpOauth && OAUTH_HOOK_PATHS.has(ctx.path)) - return oauthBeforeHook(ctx, { grants: internal.grants, staff, now: clock }); + return oauthBeforeHook(ctx as never, { grants: internal.grants, staff, now: clock }); if (!ctx.path.startsWith("/sign-up") || mode !== "first-user") return; const existing = await ctx.context.adapter.count({ model: "user" }); if (existing > 0) diff --git a/packages/api/src/auth/oauth-hooks.ts b/packages/api/src/auth/oauth-hooks.ts index a0548abc..cde8e117 100644 --- a/packages/api/src/auth/oauth-hooks.ts +++ b/packages/api/src/auth/oauth-hooks.ts @@ -6,9 +6,14 @@ * for the member and client, or when the member's current role would give a requested scope * that the grant does not hold (a promotion, then a step-up), the stored consent is deleted so * the member sees Studio's consent screen again. - * - `/oauth2/token`: no DPoP in v1 (ADR-0010), so a `DPoP` proof is refused instead of minting a - * sender-constrained token `/mcp` would not check; and a refresh token family ends 90 days after - * the member authenticated for it (§2 "90 d absolute"), however often it was rotated. + * The request's parameters are read from the query, or from the form body of a POST, as the + * provider reads them. + * - `/oauth2/token`: no DPoP in v1 (ADR-0010), so a `DPoP` proof is refused (400) instead of + * minting a sender-constrained token `/mcp` would not check, or falling back to Bearer. A refresh + * token is refused when its family began before the live grant (a revoke, then a reconnect: the + * old family never mints tokens for the new grant) and 90 days after its family began (§2 + * "90 d absolute"), however often it was rotated. A family is every refresh token of one + * authorization code (`authorizationCodeId`, kept through rotation); it begins with the first. */ import { holds, isKeyScope, narrow, roleScopes } from "@cloudhal/agent-runtime"; import { APIError, getSessionFromCtx } from "better-auth/api"; @@ -16,7 +21,7 @@ import type { StaffStore } from "../staff.ts"; import type { GrantStore } from "./grants.ts"; import { hashToken } from "./oauth.ts"; -/** §2: a refresh token family's absolute lifetime, from the authentication it began with. */ +/** §2: a refresh token family's absolute lifetime, from its first refresh token. */ export const REFRESH_FAMILY_MAX_MS = 90 * 24 * 60 * 60_000; export interface OauthHookDeps { @@ -28,12 +33,19 @@ export interface OauthHookDeps { type Where = { field: string; value: unknown }[]; interface HookContext { path?: string | undefined; + method?: string | undefined; query?: Record | undefined; body?: Record | undefined; headers?: Headers | undefined; context: { adapter: { findOne(input: { model: string; where: Where }): Promise; + findMany(input: { + model: string; + where: Where; + limit?: number | undefined; + sortBy?: { field: string; direction: "asc" | "desc" } | undefined; + }): Promise; deleteMany(input: { model: string; where: Where }): Promise; }; }; @@ -62,11 +74,12 @@ export async function consentIsStale( } async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise { - const clientId = ctx.query?.client_id; + const params = (ctx.method === "POST" ? ctx.body : ctx.query) ?? {}; + const clientId = params.client_id; if (typeof clientId !== "string" || !clientId) return; const session = await getSessionFromCtx(ctx as never).catch(() => null); if (!session) return; - const scope = typeof ctx.query?.scope === "string" ? ctx.query.scope : ""; + const scope = typeof params.scope === "string" ? params.scope : ""; const requested = scope.split(" ").filter(Boolean); if (!(await consentIsStale(d, { userId: session.user.id, clientId, requested }))) return; await ctx.context.adapter.deleteMany({ @@ -78,22 +91,49 @@ async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise + new APIError("BAD_REQUEST", { error: "invalid_grant", error_description: description }); + +/** When a refresh token's family began: its chain's first `createdAt` (ms). */ +async function familyStart(ctx: HookContext, row: RefreshRow): Promise { + if (!row.authorizationCodeId) return new Date(row.createdAt).getTime(); + const [first] = await ctx.context.adapter.findMany({ + model: "oauthRefreshToken", + where: [{ field: "authorizationCodeId", value: row.authorizationCodeId }], + sortBy: { field: "createdAt", direction: "asc" }, + limit: 1, + }); + return new Date((first ?? row).createdAt).getTime(); +} + async function checkTokenRequest(ctx: HookContext, d: OauthHookDeps): Promise { if (ctx.headers?.get("dpop")) throw new APIError("BAD_REQUEST", { error: "invalid_dpop_proof", - error_description: "DPoP is not supported by this server; use a bearer token", + error_description: DPOP_REFUSED, }); const body = ctx.body ?? {}; if (body.grant_type !== "refresh_token" || typeof body.refresh_token !== "string") return; - const row = await ctx.context.adapter.findOne<{ authTime?: Date | string | number | null }>({ + const row = await ctx.context.adapter.findOne({ model: "oauthRefreshToken", where: [{ field: "token", value: await hashToken(body.refresh_token) }], }); - const began = row?.authTime == null ? Number.NaN : new Date(row.authTime).getTime(); - if (!Number.isNaN(began) && d.now().getTime() - began > REFRESH_FAMILY_MAX_MS) - throw new APIError("BAD_REQUEST", { - error: "invalid_grant", - error_description: "this connection is older than 90 days; authorize it again", - }); + if (!row) return; // the provider answers an unknown token itself + const began = await familyStart(ctx, row); + const grant = await d.grants.live(row.userId, row.clientId); + // Seconds: the provider stamps refresh rows at second resolution, the grant to the millisecond. + if (!grant || began < Math.floor(Date.parse(grant.createdAt) / 1000) * 1000) + throw refused("this connection was revoked; authorize it again"); + if (d.now().getTime() - began > REFRESH_FAMILY_MAX_MS) + throw refused("this connection is older than 90 days; authorize it again"); } + +/** The `error_description` of a refused DPoP proof: a 400, never a fallback to Bearer. */ +export const DPOP_REFUSED = "DPoP is not supported by this server; use a bearer token"; diff --git a/packages/api/src/oauth-server.ts b/packages/api/src/oauth-server.ts index 3f84fda5..de0eef61 100644 --- a/packages/api/src/oauth-server.ts +++ b/packages/api/src/oauth-server.ts @@ -38,6 +38,9 @@ const OPEN_OAUTH_PATHS = new Set([ export function fencedOauthPath(pathname: string): boolean { const path = trimEndChar(pathname, "/"); if (path === "/api/auth/token") return true; + // The provider also answers its metadata under the issuer (`/api/auth/.well-known/…`), with + // DPoP listed; clients use the RFC 8414 documents `createApi` serves at the root. + if (path.startsWith("/api/auth/.well-known")) return true; return path.startsWith("/api/auth/oauth2") && !OPEN_OAUTH_PATHS.has(path); } diff --git a/packages/api/src/routes/members.ts b/packages/api/src/routes/members.ts index 95a041c4..b36aa258 100644 --- a/packages/api/src/routes/members.ts +++ b/packages/api/src/routes/members.ts @@ -203,13 +203,13 @@ export function memberRoutes(options: { if (!target) throw notFound("member not found"); const problem = removalProblem(person, target); if (problem) throw rankError(problem); - // 1. membership (effective at once: roles are read per request) + // 1. membership (effective at once: roles are read per request), and in the same batch their + // OAuth connections (RFC-0044) if (!(await staff.store.remove(target.userId))) throw apiError(409, "conflict", "the member changed meanwhile; reload and try again"); // 2. sessions, 3. keys, 4. their pending invites, 5. their agents' pending requests await staff.revokeSessions(target.userId); const keysDisabled = await staff.store.disableKeysOf(target.userId); - await staff.store.revokeGrantsOf(target.userId); // RFC-0044: their OAuth connections too const invitesCanceled = await staff.store.cancelInvitesBy(target.userId, person.userId); const agents = await staff.store.agentsOf(target.userId); const requestsDenied = diff --git a/packages/api/src/staff.ts b/packages/api/src/staff.ts index 123dc51a..07f2e401 100644 --- a/packages/api/src/staff.ts +++ b/packages/api/src/staff.ts @@ -8,7 +8,7 @@ * Worker-safe: no Node imports. */ import { isRole, type Role } from "@cloudhal/agent-runtime"; -import type { SqlDriver } from "@cloudhal/storage"; +import type { SqlDriver, SqlStatement } from "@cloudhal/storage"; import { providerRevocation } from "./auth/grants.ts"; import { countHit, pruneCounters, randomPrune } from "./rate-limits.ts"; import { @@ -212,15 +212,25 @@ export class StaffStore { /** Deletes a membership (never the Owner's). */ async remove(userId: string): Promise { + const gone = ` AND NOT EXISTS (SELECT 1 FROM "member" WHERE "organizationId" = ? AND "userId" = ?)`; + const unlessMember = (s: SqlStatement): SqlStatement => ({ + sql: `${s.sql}${gone}`, + params: [...(s.params ?? []), DEPLOYMENT_ORG, userId], + }); + const at = this.#stamp(); const [r] = await this.#sql.batch([ { sql: `DELETE FROM "member" WHERE "organizationId" = ? AND "userId" = ? AND "role" <> 'owner'`, params: [DEPLOYMENT_ORG, userId], }, - { - sql: `DELETE FROM site_members WHERE user_id = ? AND NOT EXISTS (SELECT 1 FROM "member" WHERE "organizationId" = ? AND "userId" = ?)`, - params: [userId, DEPLOYMENT_ORG, userId], - }, + unlessMember({ sql: "DELETE FROM site_members WHERE user_id = ?", params: [userId] }), + // RFC-0044: their OAuth connections end in the same batch (grants, the provider's consent, + // refresh tokens and pending codes), so a failure later in the removal leaves none alive. + unlessMember({ + sql: "UPDATE agent_grants SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + params: [at, userId], + }), + ...providerRevocation(userId, undefined, at).map(unlessMember), ]); return r?.changes === 1; } @@ -312,21 +322,6 @@ export class StaffStore { return rows.map((r) => r.agent_id); } - /** - * Ends every OAuth connection of a person (removal; RFC-0044 §5 inv. 4): their grants (kept for - * audit), and on the provider's side their stored consents and refresh tokens, in one batch. - */ - async revokeGrantsOf(userId: string): Promise { - const at = this.#stamp(); - await this.#sql.batch([ - { - sql: "UPDATE agent_grants SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", - params: [at, userId], - }, - ...providerRevocation(userId, undefined, at), - ]); - } - /** Disables every key of a person (removal, RFC-0005 §3); kept for audit, deletable. */ async disableKeysOf(userId: string): Promise { const r = await this.#sql.run( diff --git a/packages/api/src/testing-mcp-oauth-hardening.ts b/packages/api/src/testing-mcp-oauth-hardening.ts index f254b322..a3cedcba 100644 --- a/packages/api/src/testing-mcp-oauth-hardening.ts +++ b/packages/api/src/testing-mcp-oauth-hardening.ts @@ -5,6 +5,7 @@ */ import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { DPOP_REFUSED } from "./auth/oauth-hooks.ts"; import { authorize, connect, @@ -13,6 +14,7 @@ import { mcpCall, mcpClient, ORIGIN, + REDIRECT, setupServer, token, } from "./testing-oauth-harness.ts"; @@ -145,7 +147,16 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): }), }); expect(res.status).toBe(400); - expect(((await res.json()) as Json).error).toBe("invalid_dpop_proof"); + // Cloudhal's own refusal, not the provider's verdict on a malformed proof. + expect(await res.json()).toEqual({ + error: "invalid_dpop_proof", + error_description: DPOP_REFUSED, + }); + // The provider's own issuer-appended document (which lists DPoP) is not served. + const appended = await s.app.request( + `${ORIGIN}/api/auth/.well-known/oauth-authorization-server`, + ); + expect(appended.status).toBe(404); }); it("ends a refresh token family 90 days after the member authenticated, however often it rotated", async () => { @@ -170,5 +181,100 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): expect(late.status).toBe(400); expect(late.body.error).toBe("invalid_grant"); }); + it("never exchanges a code issued before a revoke, after the member connects again", async () => { + const s = await setupServer(backend); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + // Consent memory: a second authorization gets a code at once, before the revoke. + const pending = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const code = pending.location?.searchParams.get("code") ?? ""; + expect(code).toBeTruthy(); + const [grant] = await grantsOf(s, s.owner.cookie); + await s.call("DELETE", `/api/agent-grants/${grant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const late = await token(s, { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + code_verifier: pending.verifier, + }); + expect(late.status, JSON.stringify(late.body)).toBe(400); + expect(late.body.error).toBe("invalid_grant"); + }); + + it("never refreshes a family from before a revoke, even one that escaped the revoke batch", async () => { + const s = await setupServer(backend); + const { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + // Refresh rows are stamped in whole seconds: the reconnect comes a little later. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5_000); + const [grant] = await grantsOf(s, s.owner.cookie); + await s.call("DELETE", `/api/agent-grants/${grant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + // A rotation that raced the revoke: its child was written after the batch. + await s.sql.run(`UPDATE "oauthRefreshToken" SET "revoked" = NULL WHERE "userId" = ?`, [ + s.owner.userId, + ]); + await connect(s, { cookie: s.owner.cookie, scope: `${READ} offline_access` }); + const old = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); + expect(old.status, JSON.stringify(old.body)).toBe(400); + expect(old.body.error).toBe("invalid_grant"); + }); + + it("ends the connections in the same batch as the membership", async () => { + const s = await setupServer(backend); + const editor = await s.join("editor"); + await connect(s, { cookie: editor.cookie, scope: `${READ} offline_access` }); + // Only the membership step of a removal: nothing after it has to succeed. + expect(await s.auth.staff.store.remove(editor.userId)).toBe(true); + const count = async (sql: string) => + (await s.sql.get<{ n: number }>(sql, [editor.userId]))?.n ?? -1; + expect( + await count( + "SELECT COUNT(*) AS n FROM agent_grants WHERE user_id = ? AND revoked_at IS NULL", + ), + ).toBe(0); + expect(await count(`SELECT COUNT(*) AS n FROM "oauthConsent" WHERE "userId" = ?`)).toBe(0); + expect( + await count( + `SELECT COUNT(*) AS n FROM "oauthRefreshToken" WHERE "userId" = ? AND "revoked" IS NULL`, + ), + ).toBe(0); + }); + + for (const method of ["GET", "POST"] as const) + it(`shows the screen for a stored consent with no live grant (${method} authorize)`, async () => { + const s = await setupServer(backend); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + // The grant ends, the provider's consent stays (as before this check existed). + await s.sql.run("UPDATE agent_grants SET revoked_at = ? WHERE user_id = ?", [ + new Date().toISOString(), + s.owner.userId, + ]); + const start = await authorize(s, { cookie: s.owner.cookie, scope: READ, method }); + expect(start.location?.pathname, start.location?.href).toBe("/oauth/consent"); + }); + + it("refreshes a new connection of a member whose Studio session is older than 90 days", async () => { + const s = await setupServer(backend); + const long = new Date(Date.now() - 120 * DAY).toISOString(); + await s.sql.run(`UPDATE "session" SET "createdAt" = ? WHERE "userId" = ?`, [ + long, + s.owner.userId, + ]); + const { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + const r = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + }); }); } diff --git a/packages/api/src/testing-oauth-harness.ts b/packages/api/src/testing-oauth-harness.ts index c0109436..30d5e0ef 100644 --- a/packages/api/src/testing-oauth-harness.ts +++ b/packages/api/src/testing-oauth-harness.ts @@ -189,6 +189,8 @@ export interface AuthorizeOptions { readonly clientId?: string; readonly challengeMethod?: string | null; readonly resource?: string; + /** `POST`: the parameters as a form body (the provider accepts both). */ + readonly method?: "GET" | "POST"; } /** `GET /oauth2/authorize` as the member's browser: where it is sent next. */ @@ -206,9 +208,20 @@ export async function authorize(s: Server, o: AuthorizeOptions) { params.set("code_challenge", challenge); params.set("code_challenge_method", o.challengeMethod ?? "S256"); } - const res = await s.app.request(`${ISSUER}/oauth2/authorize?${params}`, { - headers: { cookie: o.cookie }, - }); + const res = + o.method === "POST" + ? await s.app.request(`${ISSUER}/oauth2/authorize`, { + method: "POST", + headers: { + cookie: o.cookie, + origin: ORIGIN, + "content-type": "application/x-www-form-urlencoded", + }, + body: params, + }) + : await s.app.request(`${ISSUER}/oauth2/authorize?${params}`, { + headers: { cookie: o.cookie }, + }); const location = res.headers.get("location") ?? ""; return { res, location: location ? new URL(location, ORIGIN) : undefined, verifier }; } diff --git a/packages/mcp-server/src/gate-approval.ts b/packages/mcp-server/src/gate-approval.ts index 2debb696..ba32179a 100644 --- a/packages/mcp-server/src/gate-approval.ts +++ b/packages/mcp-server/src/gate-approval.ts @@ -7,13 +7,21 @@ import { agentCredential } from "@cloudhal/agent-runtime"; import type { Release } from "@cloudhal/core-contracts"; import { canonicalInstant } from "@cloudhal/schedules"; -import type { ToolContext } from "./common.ts"; +import { type ToolContext, ToolError } from "./common.ts"; import { personApproval } from "./schedules.ts"; /** What `set_embargo` answers an OAuth connection on live content. */ export const NO_GRANT_GATES = "The embargo is saved, but an OAuth connection cannot put it live on its own: the content stays visible until a person publishes it (or an agent key sets the embargo)."; +/** The agent key of a call that passed `autoReleaseVerdict` (which refuses anything else). */ +export function keyOf(ctx: ToolContext): string { + const credential = agentCredential(ctx.principal); + if (credential?.kind !== "key") + throw new ToolError("denied", "auto-mode releases need an API key"); + return credential.id; +} + /** The approval, or `undefined` when the caller is an OAuth connection (it waits for a person). */ export function directApproval(ctx: ToolContext, at: string): Release["approval"] | undefined { const credential = agentCredential(ctx.principal); diff --git a/packages/mcp-server/src/schedule-tools.ts b/packages/mcp-server/src/schedule-tools.ts index 91adb5e6..792256b5 100644 --- a/packages/mcp-server/src/schedule-tools.ts +++ b/packages/mcp-server/src/schedule-tools.ts @@ -62,7 +62,7 @@ import { type ToolDefinition, ToolError, } from "./common.ts"; -import { directApproval, NO_GRANT_GATES } from "./gate-approval.ts"; +import { directApproval, keyOf, NO_GRANT_GATES } from "./gate-approval.ts"; import { approvalProblem, autoReleaseVerdict, @@ -307,7 +307,7 @@ async function decideRelease( release: await scheduleWith(ctx, record, when, { kind: "auto", by: ctx.principal.actor, - keyId: ctx.principal.keyId as string, + keyId: keyOf(ctx), at, windowEnds: verdict.windowEnds, }), From 8d236e2d9a7e2e2291bb6e76e6565836676bf407 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:30:06 +0300 Subject: [PATCH 4/5] fix(api): mirror the provider's authorize parameters; check codes (#144) Third verification round of RFC-0044 phase 2 (PR #176): - The stale-consent check reads a POST body only for an HTTP POST /oauth2/authorize (authorizeSettings.isAuthorize), as the provider does; after sign-in it reads the resumed request's query again (regression from b5968e1a). - Before a code exchange, the code must belong to a live grant of its member and client and be no older than it. - A refresh token without a family (no authorizationCodeId) is refused. - Migration 0031 (unreleased) gains the composite index oauthRefreshToken (authorizationCodeId, createdAt) for the family lookup. - Tests: sign-in after a stale consent, the code race, the closed fallback, a step-up keeping the first refresh token, pending codes removed with the member, 415 for a JSON authorize body. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- packages/api/src/auth/agent-consent.ts | 5 +- packages/api/src/auth/oauth-hooks.ts | 53 +++++++++- .../api/src/testing-mcp-oauth-hardening.ts | 96 ++++++++++++++++++- packages/storage/migrations/0031_oauth.ts | 4 + packages/storage/test/migrate.test.ts | 4 + 5 files changed, 154 insertions(+), 8 deletions(-) diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts index 94bd2d98..e7374bbc 100644 --- a/packages/api/src/auth/agent-consent.ts +++ b/packages/api/src/auth/agent-consent.ts @@ -224,8 +224,9 @@ export async function giveConsent( ...(requested.includes(OFFLINE_ACCESS) ? [OFFLINE_ACCESS] : []), ]; const now = d.now().toISOString(); - // The grant first: the provider's consent runs its authorize step, whose check - // (`oauth-hooks.ts`) forgets any stored consent the live grant does not cover. One batch: the + // The grant first: the provider's consent resumes its authorize step, and Cloudhal's check of + // that step (`oauth-hooks.ts`, reading the request's query) forgets a stored consent the live + // grant does not cover, and the code exchange accepts only codes newer than the live grant. One batch: the // grant, the person behind the agent actor (RFC-0005 §4, as `recordAgent` does for a key) and // the audit row. If the provider call then fails: a new grant has no token yet; on a step-up of // a live grant, the scopes and mode the member approved already apply to the connection's diff --git a/packages/api/src/auth/oauth-hooks.ts b/packages/api/src/auth/oauth-hooks.ts index cde8e117..c8b1a033 100644 --- a/packages/api/src/auth/oauth-hooks.ts +++ b/packages/api/src/auth/oauth-hooks.ts @@ -34,6 +34,8 @@ type Where = { field: string; value: unknown }[]; interface HookContext { path?: string | undefined; method?: string | undefined; + /** Set when the provider resumes authorize itself (after sign-in, consent, continue). */ + authorizeSettings?: { isAuthorize?: boolean } | undefined; query?: Record | undefined; body?: Record | undefined; headers?: Headers | undefined; @@ -74,7 +76,12 @@ export async function consentIsStale( } async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise { - const params = (ctx.method === "POST" ? ctx.body : ctx.query) ?? {}; + // Exactly as the provider reads them (`getAuthorizationRequestParameters`): the form body only + // for an HTTP `POST /oauth2/authorize`; when it resumes authorize after a sign-in or a consent + // (a POST whose body is that request's), the parameters are in the query. + const settings = ctx.authorizeSettings ?? { isAuthorize: true }; + const fromBody = ctx.method === "POST" && settings.isAuthorize === true; + const params = (fromBody ? ctx.body : ctx.query) ?? {}; const clientId = params.client_id; if (typeof clientId !== "string" || !clientId) return; const session = await getSessionFromCtx(ctx as never).catch(() => null); @@ -102,8 +109,9 @@ const refused = (description: string) => new APIError("BAD_REQUEST", { error: "invalid_grant", error_description: description }); /** When a refresh token's family began: its chain's first `createdAt` (ms). */ -async function familyStart(ctx: HookContext, row: RefreshRow): Promise { - if (!row.authorizationCodeId) return new Date(row.createdAt).getTime(); +async function familyStart(ctx: HookContext, row: RefreshRow): Promise { + // Every family the code flow starts has one; without it the start is unknown: fail closed. + if (!row.authorizationCodeId) return undefined; const [first] = await ctx.context.adapter.findMany({ model: "oauthRefreshToken", where: [{ field: "authorizationCodeId", value: row.authorizationCodeId }], @@ -120,6 +128,8 @@ async function checkTokenRequest(ctx: HookContext, d: OauthHookDeps): Promise({ model: "oauthRefreshToken", @@ -127,13 +137,46 @@ async function checkTokenRequest(ctx: HookContext, d: OauthHookDeps): Promise REFRESH_FAMILY_MAX_MS) throw refused("this connection is older than 90 days; authorize it again"); } +/** + * Seconds: the provider stamps refresh rows at second resolution, the grant to the millisecond; + * a family or code from the second the grant began counts as the grant's. + */ +function wholeSeconds(iso: string): number { + return Math.floor(Date.parse(iso) / 1000) * 1000; +} + +/** + * An authorization code is exchanged only for the live grant it was issued under: one written + * just after a revoke (a race with the revoke batch) is older than the grant of a reconnect. + */ +async function checkCode(ctx: HookContext, d: OauthHookDeps, code: string): Promise { + const row = await ctx.context.adapter.findOne<{ value: string; createdAt: Date | string }>({ + model: "verification", + where: [{ field: "identifier", value: await hashToken(code) }], + }); + if (!row) return; // the provider answers an unknown code itself + let value: { type?: unknown; userId?: unknown; query?: { client_id?: unknown } }; + try { + value = JSON.parse(row.value) as typeof value; + } catch { + return; + } + if (value.type !== "authorization_code") return; + const userId = typeof value.userId === "string" ? value.userId : ""; + const clientId = typeof value.query?.client_id === "string" ? value.query.client_id : ""; + const grant = await d.grants.live(userId, clientId); + if (!grant || new Date(row.createdAt).getTime() < wholeSeconds(grant.createdAt)) + throw refused("this authorization was revoked; authorize again"); +} + /** The `error_description` of a refused DPoP proof: a 400, never a fallback to Bearer. */ export const DPOP_REFUSED = "DPoP is not supported by this server; use a bearer token"; diff --git a/packages/api/src/testing-mcp-oauth-hardening.ts b/packages/api/src/testing-mcp-oauth-hardening.ts index a3cedcba..6c4a0ace 100644 --- a/packages/api/src/testing-mcp-oauth-hardening.ts +++ b/packages/api/src/testing-mcp-oauth-hardening.ts @@ -159,7 +159,7 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): expect(appended.status).toBe(404); }); - it("ends a refresh token family 90 days after the member authenticated, however often it rotated", async () => { + it("ends a refresh token family 90 days after its first refresh token, however often it rotated", async () => { const s = await setupServer(backend); let { refresh } = await connect(s, { cookie: s.owner.cookie, @@ -232,6 +232,9 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): const s = await setupServer(backend); const editor = await s.join("editor"); await connect(s, { cookie: editor.cookie, scope: `${READ} offline_access` }); + // A pending code (consent memory answers at once), never exchanged. + const pending = await authorize(s, { cookie: editor.cookie, scope: READ }); + expect(pending.location?.searchParams.get("code")).toBeTruthy(); // Only the membership step of a removal: nothing after it has to succeed. expect(await s.auth.staff.store.remove(editor.userId)).toBe(true); const count = async (sql: string) => @@ -242,6 +245,11 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): ), ).toBe(0); expect(await count(`SELECT COUNT(*) AS n FROM "oauthConsent" WHERE "userId" = ?`)).toBe(0); + expect( + await count( + `SELECT COUNT(*) AS n FROM "verification" WHERE "value" LIKE '%authorization_code%' AND instr("value", ?) > 0`, + ), + ).toBe(0); expect( await count( `SELECT COUNT(*) AS n FROM "oauthRefreshToken" WHERE "userId" = ? AND "revoked" IS NULL`, @@ -276,5 +284,91 @@ export function describeMcpOauthHardening(label: string, backend: RolesBackend): const r = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); expect(r.status, JSON.stringify(r.body)).toBe(200); }); + it("shows the screen after sign-in when the stored consent is stale", async () => { + const s = await setupServer(backend); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + await s.sql.run("UPDATE agent_grants SET revoked_at = ? WHERE user_id = ?", [ + new Date().toISOString(), + s.owner.userId, + ]); + // Signed out: the provider sends the browser to sign in, then resumes authorize itself. + const start = await authorize(s, { cookie: "", scope: READ }); + expect(start.location?.pathname).toBe("/login"); + const signIn = await s.call("POST", "/api/auth/sign-in/email", { + body: { + email: "owner@example.com", + password: "correct horse battery staple", + oauth_query: (start.location as URL).search.slice(1), + }, + }); + expect(signIn.status, JSON.stringify(signIn.body)).toBe(200); + expect(new URL(signIn.body.url as string, ORIGIN).pathname).toBe("/oauth/consent"); + }); + + it("refuses a code written just after a revoke, once the member connects again", async () => { + const s = await setupServer(backend); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const pending = await authorize(s, { cookie: s.owner.cookie, scope: READ }); + const code = pending.location?.searchParams.get("code") ?? ""; + const row = await s.sql.get>( + `SELECT * FROM "verification" WHERE "value" LIKE '%authorization_code%' ORDER BY "createdAt" DESC LIMIT 1`, + ); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5_000); + const [grant] = await grantsOf(s, s.owner.cookie); + await s.call("DELETE", `/api/agent-grants/${grant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + // The race: the provider's write of that code lands after the revoke batch. + const cols = Object.keys(row ?? {}); + await s.sql.run( + `INSERT INTO "verification" (${cols.map((c) => `"${c}"`).join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`, + cols.map((c) => (row?.[c] ?? null) as string), + ); + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const late = await token(s, { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + code_verifier: pending.verifier, + }); + expect(late.status, JSON.stringify(late.body)).toBe(400); + expect(late.body.error).toBe("invalid_grant"); + }); + + it("refuses a refresh token whose family is unknown (fails closed)", async () => { + const s = await setupServer(backend); + const { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + await s.sql.run(`UPDATE "oauthRefreshToken" SET "authorizationCodeId" = NULL`); + const r = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); + expect(r.body.error).toBe("invalid_grant"); + }); + + it("keeps the first refresh token working after a step-up of the live grant", async () => { + const s = await setupServer(backend); + const { refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 5_000); + await connect(s, { cookie: s.owner.cookie, scope: `${READ} site:publish offline_access` }); + const r = await token(s, { grant_type: "refresh_token", refresh_token: refresh as string }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + }); + + it("refuses a JSON body on POST /oauth2/authorize", async () => { + const s = await setupServer(backend); + const res = await s.app.request(`${ORIGIN}/api/auth/oauth2/authorize`, { + method: "POST", + headers: { "content-type": "application/json", cookie: s.owner.cookie, origin: ORIGIN }, + body: JSON.stringify({ client_id: s.clientId, response_type: "code" }), + }); + expect(res.status).toBe(415); + }); }); } diff --git a/packages/storage/migrations/0031_oauth.ts b/packages/storage/migrations/0031_oauth.ts index b1e0fea1..72cd1d97 100644 --- a/packages/storage/migrations/0031_oauth.ts +++ b/packages/storage/migrations/0031_oauth.ts @@ -14,6 +14,9 @@ import type { Migration } from "../src/migrate.ts"; * screen. It carries what a token does not: the scopes after narrowing by the member's role, * the agent mode, an elevation and the revocation, read on every `/mcp` call (§5). * + * 3. Cloudhal's index `oauthRefreshToken_family_idx` (not generated): every refresh finds its + * family's first token (`authorizationCodeId`, oldest `createdAt`; rotated rows are kept). + * * Plain SQL, one atomic batch; additive, so a downgrade ignores the tables (§7 rollback). */ export const migration0031Oauth: Migration = { @@ -42,6 +45,7 @@ create index "oauthAccessToken_refreshId_idx" on "oauthAccessToken" ("refreshId" create index "oauthConsent_clientId_idx" on "oauthConsent" ("clientId"); create index "oauthConsent_userId_idx" on "oauthConsent" ("userId"); create unique index "oauthClientResource_clientId_resourceId_uidx" on "oauthClientResource" ("clientId", "resourceId"); +CREATE INDEX "oauthRefreshToken_family_idx" ON "oauthRefreshToken" ("authorizationCodeId", "createdAt"); CREATE TABLE agent_grants ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/packages/storage/test/migrate.test.ts b/packages/storage/test/migrate.test.ts index 2f31dec7..b77bc81f 100644 --- a/packages/storage/test/migrate.test.ts +++ b/packages/storage/test/migrate.test.ts @@ -380,6 +380,10 @@ describe("0031_oauth: the authorization server's tables (RFC-0044 §7)", () => { "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'agent_grants_user'", ); expect(index?.name).toBe("agent_grants_user"); + const family = await driver.get<{ sql: string }>( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'oauthRefreshToken_family_idx'", + ); + expect(family?.sql).toContain('("authorizationCodeId", "createdAt")'); await (h ? h.dispose() : driver.close()); }); }); From cfb3c95d027375d23cedb5386d4acac702aead75 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:51:06 +0300 Subject: [PATCH 5/5] fix(api): grant ids by rejection sampling, no modulo bias (#144) CodeQL js/biased-cryptographic-random on newGrantId: mapping a random byte onto 62 characters with b % 62 made the first eight characters 5/4 as likely. Bytes at or above 248 are now dropped; a test checks the length, the alphabet and an even spread. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- packages/api/src/auth/grants.ts | 17 +++++++++++++---- packages/api/test/grant-id.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 packages/api/test/grant-id.test.ts diff --git a/packages/api/src/auth/grants.ts b/packages/api/src/auth/grants.ts index f193fa88..f7e655c8 100644 --- a/packages/api/src/auth/grants.ts +++ b/packages/api/src/auth/grants.ts @@ -239,11 +239,20 @@ export function providerRevocation( ]; } -/** A grant id: 24 base62 characters from WebCrypto. */ +const GRANT_ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const GRANT_ID_LENGTH = 24; +/** The largest multiple of 62 below 256: bytes at or above it are dropped, so no bias. */ +const UNBIASED_LIMIT = 256 - (256 % GRANT_ID_ALPHABET.length); + +/** A grant id: 24 base62 characters from WebCrypto, by rejection sampling (no modulo bias). */ export function newGrantId(): string { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const bytes = crypto.getRandomValues(new Uint8Array(24)); let out = ""; - for (const b of bytes) out += alphabet[b % alphabet.length]; + while (out.length < GRANT_ID_LENGTH) { + for (const b of crypto.getRandomValues(new Uint8Array(GRANT_ID_LENGTH))) { + if (b >= UNBIASED_LIMIT) continue; + out += GRANT_ID_ALPHABET[b % GRANT_ID_ALPHABET.length]; + if (out.length === GRANT_ID_LENGTH) break; + } + } return out; } diff --git a/packages/api/test/grant-id.test.ts b/packages/api/test/grant-id.test.ts new file mode 100644 index 00000000..68d37538 --- /dev/null +++ b/packages/api/test/grant-id.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { newGrantId } from "../src/auth/grants.ts"; + +describe("newGrantId", () => { + it("is 24 base62 characters", () => { + for (let i = 0; i < 200; i++) expect(newGrantId()).toMatch(/^[A-Za-z0-9]{24}$/); + }); + + it("draws every character evenly (rejection sampling, no modulo bias)", () => { + const counts = new Map(); + const ids = 4_000; + for (let i = 0; i < ids; i++) + for (const ch of newGrantId()) counts.set(ch, (counts.get(ch) ?? 0) + 1); + expect(counts.size).toBe(62); + const expected = (ids * 24) / 62; + // With `b % 62` the first 8 characters came up 5/4 as often; allow ±20% around the mean. + for (const n of counts.values()) { + expect(n).toBeGreaterThan(expected * 0.8); + expect(n).toBeLessThan(expected * 1.2); + } + }); +});