From d363160313710e71bef6af3c173352f763335d25 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:57:47 +0300 Subject: [PATCH 1/7] feat(api): MCP OAuth client registration (CIMD and DCR), RFC-0044 phase 3a (#144) Remote MCP clients can now get a client_id themselves. - CIMD: @better-auth/cimd 1.7.18 with the MCP 2026-07-28 profile behind cimdFetch, which puts the media import's SSRF-safe safeFetch behind the plugin's fetch (nodeTransport on Node, workersTransport on Workers; https/443, DNS names only, public addresses, no redirects, 5 KB, 5 s, never the deployment's own host or a site domain). Fetch budget across isolates in rate_limits; optional CLOUDHAL_MCP_CLIENT_HOSTS allow-list. - DCR: Cloudhal's POST /api/auth/oauth2/register in front of the provider (public clients only, 10/address/hour and 300/day in rate_limits, 5 KB, a cap of 5,000, unknown fields dropped). The provider's own endpoint stays unreachable over HTTP. Unused clients are pruned by the hourly oauth.clients.prune job on both schedulers. CLOUDHAL_MCP_DCR=off and CLOUDHAL_MCP_CIMD=off switch them. - Redirect URIs: https, or loopback http on any port (RFC 8252), checked at registration and at every authorization. - The Phase 2 server-side test client (OauthServer.createClient) is removed; the suites register through DCR or CIMD. - set_embargo through an OAuth connection files the gate-only release for a person's approval. - AS metadata lists none and private_key_jwt as token auth methods. - Shared suite on sqlite and D1; hardening guide, self-host table, Docker compose variables, ADR-0045 Phase 3a section; changeset. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/mcp-oauth-client-registration.md | 9 + deploy/docker/.env.example | 9 + deploy/docker/compose.yml | 6 + docs/adr/0045-mcp-authorization-server.md | 12 +- docs/guides/cloudflare-hardening.md | 41 ++ docs/guides/self-host.md | 2 + packages/api/package.json | 1 + packages/api/src/app.ts | 21 +- packages/api/src/auth.ts | 38 +- packages/api/src/auth/cimd-fetch.ts | 104 ++++ packages/api/src/auth/dcr-server.ts | 64 +++ packages/api/src/auth/dcr.ts | 140 ++++++ packages/api/src/auth/instance.ts | 14 +- packages/api/src/auth/oauth-cimd-policy.ts | 45 ++ packages/api/src/auth/oauth-clients.ts | 48 -- packages/api/src/auth/oauth-registration.ts | 164 +++++++ packages/api/src/auth/oauth.ts | 62 ++- packages/api/src/auth/redirect-policy.ts | 93 ++++ packages/api/src/auth/types.ts | 17 +- packages/api/src/index.ts | 1 + packages/api/src/oauth-prune-job.ts | 25 + packages/api/src/oauth-server.ts | 86 +++- .../api/src/testing-mcp-oauth-registration.ts | 444 ++++++++++++++++++ packages/api/src/testing-mcp-oauth-server.ts | 15 +- packages/api/src/testing-oauth-harness.ts | 60 ++- packages/api/src/testing-schedules.ts | 34 ++ packages/api/test/better-auth-pins.test.ts | 1 + .../api/test/mcp-oauth-registration.test.ts | 34 ++ packages/cli/src/commands/scheduler-local.ts | 3 +- packages/cli/src/commands/studio.ts | 30 +- packages/cloudflare/src/admin-worker.ts | 36 +- packages/cloudflare/src/scheduler.ts | 3 +- packages/core-ts/src/media/fetcher.ts | 3 + packages/mcp-server/src/gate-approval.ts | 10 +- packages/mcp-server/src/schedule-gates.ts | 78 +++ packages/mcp-server/src/schedule-tools.ts | 118 ++--- packages/mcp-server/test/schedules.test.ts | 26 +- pnpm-lock.yaml | 21 + pnpm-workspace.yaml | 1 + scripts/size-baseline.json | 6 +- 40 files changed, 1720 insertions(+), 205 deletions(-) create mode 100644 .changeset/mcp-oauth-client-registration.md create mode 100644 packages/api/src/auth/cimd-fetch.ts create mode 100644 packages/api/src/auth/dcr-server.ts create mode 100644 packages/api/src/auth/dcr.ts create mode 100644 packages/api/src/auth/oauth-cimd-policy.ts delete mode 100644 packages/api/src/auth/oauth-clients.ts create mode 100644 packages/api/src/auth/oauth-registration.ts create mode 100644 packages/api/src/auth/redirect-policy.ts create mode 100644 packages/api/src/oauth-prune-job.ts create mode 100644 packages/api/src/testing-mcp-oauth-registration.ts create mode 100644 packages/api/test/mcp-oauth-registration.test.ts create mode 100644 packages/mcp-server/src/schedule-gates.ts diff --git a/.changeset/mcp-oauth-client-registration.md b/.changeset/mcp-oauth-client-registration.md new file mode 100644 index 00000000..748fc7ef --- /dev/null +++ b/.changeset/mcp-oauth-client-registration.md @@ -0,0 +1,9 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cloudflare": patch +"@cloudhal/cli": patch +"@cloudhal/mcp-server": patch +"@cloudhal/core-ts": patch +--- + +Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Redirect URIs must be https or loopback http (any port on loopback). `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off` and `CLOUDHAL_MCP_CLIENT_HOSTS` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied. `safeFetch` results carry the response headers. diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 79adf905..e1943a74 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -47,6 +47,15 @@ CLOUDHAL_AUTOMATIONS=on # Extra origins the API accepts browser requests from, comma-separated (normally empty). CLOUDHAL_TRUSTED_ORIGINS= +# Remote MCP clients (Claude, ChatGPT, Cursor, VS Code) connecting by URL over OAuth; needs +# CLOUDHAL_BASE_URL. `off` turns OAuth off, or only Client ID Metadata Documents (CIMD), or only +# dynamic client registration (DCR; Cursor needs it). The host list (comma-separated, `*.host` +# allowed) limits the hosts a client's metadata document or redirect may use; empty: any. +CLOUDHAL_MCP_OAUTH=on +CLOUDHAL_MCP_CIMD=on +CLOUDHAL_MCP_DCR=on +CLOUDHAL_MCP_CLIENT_HOSTS= + # Secrets, each passed to the container only when set here or in the shell. # Better Auth's secret is created on the volume at first start (/data/.cloudhal/auth-secret) and # kept there; set this only to supply your own (32 or more random characters). Never leave it empty. diff --git a/deploy/docker/compose.yml b/deploy/docker/compose.yml index 2d0b6238..6a6bcf96 100644 --- a/deploy/docker/compose.yml +++ b/deploy/docker/compose.yml @@ -24,6 +24,12 @@ services: - CLOUDHAL_SITE_URL=${CLOUDHAL_SITE_URL:-} - CLOUDHAL_AUTOMATIONS=${CLOUDHAL_AUTOMATIONS:-on} - CLOUDHAL_TRUSTED_ORIGINS=${CLOUDHAL_TRUSTED_ORIGINS:-} + # Remote MCP clients (RFC-0044; docs/guides/mcp-oauth.md): `off` turns OAuth, metadata + # documents or dynamic registration off; the host list limits where clients come from. + - CLOUDHAL_MCP_OAUTH=${CLOUDHAL_MCP_OAUTH:-on} + - CLOUDHAL_MCP_CIMD=${CLOUDHAL_MCP_CIMD:-on} + - CLOUDHAL_MCP_DCR=${CLOUDHAL_MCP_DCR:-on} + - CLOUDHAL_MCP_CLIENT_HOSTS=${CLOUDHAL_MCP_CLIENT_HOSTS:-} # Secrets. A name without a value is passed through from .env or the shell only when it is # set there, so an unset BETTER_AUTH_SECRET leaves the container with the secret it created # on the volume (/data/.cloudhal/auth-secret). Never set it to an empty value. diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index f32230bd..991518d9 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -13,7 +13,15 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve 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. +6. **Registration (Phase 3a, amended 2026-11-09).** Phase 2 had none and a server-side test client (`OauthServer.createClient`); Phase 3a replaces it with the two paths of RFC-0044 §3, and `createClient` is gone (the suites register through DCR or CIMD; `clientPrivileges` now admits nobody). See "Phase 3a" below. + +## Phase 3a: client registration (2026-11-09) +1. **CIMD** is `@better-auth/cimd` 1.7.18's `cimd()` with the MCP 2026-07-28 profile, behind a Cloudhal transport (`cimdFetch`) over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile, and caches per plugin instance (500 entries, at most 60 min). A Worker builds the instance per request, so the fetch budget that bounds amplification lives in `rate_limits` (30 per origin and 120 in all per minute, `oauth-cimd-policy.ts`). `CLOUDHAL_MCP_CIMD=off` turns it off. +2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers, with every provider row of theirs in one batch. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`. +3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host, or http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching), at most 10; private-use schemes are refused for both paths. The rule runs at registration (DCR) and at every authorization (`validateRedirectUri`), so a CIMD document's other URIs are stored but never used. +4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from and a DCR client may redirect to (`localhost` admits loopback). A value that does not parse turns registration off. +5. **AS metadata** advertises `client_id_metadata_document_supported` with CIMD on, `registration_endpoint` with DCR on, and `token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]` (the provider's shared-secret methods are dropped: no client here holds a secret). +6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; a person approves it in Studio and it goes live with their approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken. ## Alternatives considered | Alternative | Pros | Cons | Why not | @@ -24,6 +32,6 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve | 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. +- Positive: Claude, ChatGPT, Cursor and other remote MCP clients can connect by URL (Phase 3a: CIMD, and DCR for clients without a document); 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/guides/cloudflare-hardening.md b/docs/guides/cloudflare-hardening.md index 9f1dd9b8..ff24f825 100644 --- a/docs/guides/cloudflare-hardening.md +++ b/docs/guides/cloudflare-hardening.md @@ -72,3 +72,44 @@ The WAF works only on a zone, so it applies when you serve the site or Studio on - Whether it deploys on Workers Free has **not been verified** yet, so it stays opt-in. - To try it, set `"rateLimit": true` under `analytics` or `comments` and deploy. If wrangler refuses the binding, remove the key: the app-level checks keep working without it. - The binding fails open: when it cannot answer, the request goes through. + +## Remote MCP clients (OAuth) + +Claude, ChatGPT, Cursor, VS Code and the MCP Inspector connect to `https:///mcp` by URL: the admin Worker is their OAuth 2.1 authorization server ([RFC-0044](../rfcs/0044-mcp-oauth-authorization.md), [ADR-0045](../adr/0045-mcp-authorization-server.md)). A member approves each connection on Studio's consent screen, and a connection never holds more than an agent key of that member would. Agent keys keep working next to it. + +### The switches + +| Variable | Default | What it does | +| --- | --- | --- | +| `CLOUDHAL_MCP_OAUTH` | on | `off` turns the authorization server off: `/mcp` takes agent keys and Studio sessions only. | +| `CLOUDHAL_MCP_CIMD` | on | `off`: clients can no longer identify themselves with a Client ID Metadata Document (an HTTPS URL as their `client_id`, as Claude Code, Codex and VS Code do). | +| `CLOUDHAL_MCP_DCR` | on | `off`: no dynamic client registration (`/api/auth/oauth2/register`), and the metadata stops advertising it. Cursor has no metadata document and needs it. | +| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Comma-separated hosts clients may come from, for example `claude.ai,chatgpt.com,vscode.dev,*.cursor.com,localhost`. Empty: any host. | + +With CIMD and DCR both off, no client can connect: an unknown `client_id` is refused at `/oauth2/authorize`. + +On the admin Worker, set them as secrets, which `cloudhal deploy` keeps (it rewrites the Worker's plain vars on every deploy): + +```bash +npx wrangler secret put CLOUDHAL_MCP_DCR --name -admin +``` + +On Node (`cloudhal studio`, the Docker image) they are environment variables; `compose.yml` passes all four through from `.env`. + +### What a client can and cannot do + +- **Metadata documents (CIMD).** The `client_id` must be an HTTPS URL with a path, and the document there must name exactly that URL as its `client_id`. The server fetches it with the same SSRF-safe fetcher media imports use: HTTPS on port 443, DNS names only (no IP addresses), every resolved address public (on Node the connection is pinned to the checked address), no redirects followed, at most 5 KB, 5 seconds. It never fetches from the admin Worker's own host or a site's domain. Fetches are budgeted across isolates: 30 a minute per client origin and 120 a minute in all. +- **Dynamic registration (DCR).** Anonymous, so it is limited: 10 registrations per client address per hour and 300 per installation per day (invalid attempts count too), at most 5,000 registered clients (`503` beyond), a body of at most 5 KB. Only public clients register: no client secret is ever issued. A client's name (1–100 characters), up to 10 redirect URIs, and the grant and response types are kept; logos, URIs, contacts and keys are dropped, never stored or shown. A client nobody connected, used or disconnected in 30 days is deleted by the scheduler's hourly job; a client with a live connection is never pruned. +- **Redirect URIs.** `https://` on a public host, or `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, where the port may change between runs (RFC 8252 §7.3). Everything else must match the registered URI exactly. Custom schemes (`cursor://…`) are refused. +- **The host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a metadata document must be served from a listed host, and a dynamically registered client may redirect only to listed hosts. Loopback redirects then need `localhost` on the list. The list applies to new registrations and to every authorization, so removing a host cuts off its clients at their next sign-in. A list that does not parse turns client registration off, and the log says why. +- **Turning DCR off.** When every client your members use has a metadata document (Claude, Claude Code, Codex and VS Code do), `CLOUDHAL_MCP_DCR=off` removes the only anonymous write endpoint. Clients registered earlier keep their connections and age out like any other. + +### Rotating `BETTER_AUTH_SECRET` + +The secret also encrypts the key that signs access tokens. A new secret signs every member out, and the stored signing key can no longer be decrypted, so the server cannot issue tokens until that key is deleted and a new one is created on the next sign-in. After rotating it: + +```bash +npx wrangler d1 execute -db --remote --command 'DELETE FROM jwks' +``` + +On Node, run `DELETE FROM jwks` on the project's `data.db`. Access tokens signed with the old key stop verifying at once, so every connected client is asked to sign in again on its next call (one holding a refresh token may get a new token without the member). Connections stay listed under the members who made them, and revoking one still ends it at once. diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md index c625f57f..07d8c785 100644 --- a/docs/guides/self-host.md +++ b/docs/guides/self-host.md @@ -79,6 +79,8 @@ All of these are read by `cloudhal studio` in the container, except those marked | `CLOUDHAL_SITE_NAME` | `My site` | The name of that first site. | | `CLOUDHAL_AUTOMATIONS` | `on` | `off` stops scheduled automations; releases and gates go on ([schedules](schedules.md)). | | `CLOUDHAL_TRUSTED_ORIGINS` | empty | Extra origins the API accepts browser requests from, comma-separated. | +| `CLOUDHAL_MCP_OAUTH`, `CLOUDHAL_MCP_CIMD`, `CLOUDHAL_MCP_DCR` | `on` | `off` turns off OAuth for remote MCP clients, or only one way clients register: metadata documents (CIMD) or dynamic registration (DCR). OAuth needs `CLOUDHAL_BASE_URL`. See [hardening](cloudflare-hardening.md#remote-mcp-clients-oauth). | +| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Hosts OAuth clients may come from, comma-separated (`claude.ai,*.example.com`, `localhost` for loopback redirects). Empty: any host. | | `BETTER_AUTH_SECRET` | created on the volume | Better Auth's secret. Unset, the container creates one at first start and keeps it at `/data/.cloudhal/auth-secret`; set it only to supply your own, and never empty. | | `CLOUDHAL_AI_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | empty | Workers AI over REST for Build chat (both or neither). The token is kept in the container's memory, never written to the volume. | diff --git a/packages/api/package.json b/packages/api/package.json index 13ccbdfe..20b25509 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -38,6 +38,7 @@ "better-auth": "catalog:", "@better-auth/api-key": "catalog:", "@better-auth/mcp": "catalog:", + "@better-auth/cimd": "catalog:", "@better-auth/oauth-provider": "catalog:", "@better-auth/core": "catalog:", "@better-auth/utils": "catalog:", diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 98e79cc6..d5ee5e81 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -13,7 +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 { fencedOauthPath, mountClientRegistration, 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"; @@ -163,7 +163,7 @@ export function createApi(deps: ApiDeps) { 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 (fencedAuthPath(new URL(c.req.url).pathname) || fencedAuthPath(c.req.path)) + if (fencedAuthPath(new URL(c.req.url).pathname, deps) || fencedAuthPath(c.req.path, deps)) return c.json({ error: "not_found", message: "not found" }, 404); const principal = await resolvePrincipal(c, deps); c.set("principal", principal); @@ -185,10 +185,7 @@ export function createApi(deps: ApiDeps) { const problems = mountAdmin(app, deps); app.get("/api/health", (c) => c.json(problems.length ? { ok: true, problems } : { ok: true })); - if (deps.authHandler) { - const handler = deps.authHandler; - app.on(["GET", "POST"], "/api/auth/*", (c) => handler(c.req.raw)); - } + mountAuthHandler(app, deps); app.get("/api/me", meHandler(membership)); @@ -269,8 +266,16 @@ export function createApi(deps: ApiDeps) { } /** 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); +function fencedAuthPath(path: string, deps: ApiDeps): boolean { + const open = { register: Boolean(deps.oauthServer?.register) }; + return FENCED_AUTH_PATHS.test(path) || fencedOauthPath(path, open); +} + +/** Better Auth's routes; RFC-0044 §3: DCR is Cloudhal's route, ahead of the catch-all. */ +function mountAuthHandler(app: Hono, deps: ApiDeps): void { + if (deps.oauthServer?.register) mountClientRegistration(app, deps.oauthServer.register); + const handler = deps.authHandler; + if (handler) app.on(["GET", "POST"], "/api/auth/*", (c) => handler(c.req.raw)); } /** diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 07778268..b6c57144 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -1,8 +1,8 @@ import type { Auth } from "better-auth"; import { createAgentGrantService } from "./auth/agent-grants.ts"; import { createAgentKeyService } from "./auth/agent-keys.ts"; +import { oauthClientRegistration } from "./auth/dcr-server.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"; @@ -15,6 +15,13 @@ export { type ConsentScope, consentScopes, } from "./auth/agent-grants.ts"; +export { + CIMD_MAX_BYTES, + CIMD_TIMEOUT_MS, + type CimdFetchOptions, + cimdFetch, +} from "./auth/cimd-fetch.ts"; +export { DCR_MAX_BODY_BYTES, RegistrationError } from "./auth/dcr.ts"; export { agentIdForGrant } from "./auth/grants.ts"; export { actorIdForUser, agentIdForKey, agentKeyStateFromSql } from "./auth/key-rows.ts"; export { @@ -23,6 +30,15 @@ export { OAUTH_SCOPES, oauthEndpoints, } from "./auth/oauth.ts"; +export { + DCR_LIMITS, + type DcrLimits, + type OauthRegistration, + type OauthRegistrationEnv, + oauthRegistrationFrom, + parseClientHosts, + pruneDcrClients, +} from "./auth/oauth-registration.ts"; export { ADDABLE_KEY_SCOPES, AGENT_KEY_RATE_LIMIT, @@ -75,11 +91,27 @@ export function createAuth(options: AuthOptions): CloudhalAuth { getOAuthServerConfig(): Promise>; } ).getOAuthServerConfig()) ?? {}; - return metadata; + return narrowAuthMethods(metadata); }, - createClient: (input) => createOauthClient(d, input), + ...oauthClientRegistration(d, options.mcpOauth), }, } : {}), }; } + +/** + * RFC-0044 §2: the token endpoint takes public clients and `private_key_jwt` only. The provider + * also lists the shared-secret methods, but no client here ever holds a secret (CIMD and DCR + * clients are public or key-bound), so they are not advertised. + */ +function narrowAuthMethods(metadata: Record): Record { + const methods = metadata.token_endpoint_auth_methods_supported; + if (!Array.isArray(methods)) return metadata; + return { + ...metadata, + token_endpoint_auth_methods_supported: methods.filter( + (m) => m === "none" || m === "private_key_jwt", + ), + }; +} diff --git a/packages/api/src/auth/cimd-fetch.ts b/packages/api/src/auth/cimd-fetch.ts new file mode 100644 index 00000000..16c33f93 --- /dev/null +++ b/packages/api/src/auth/cimd-fetch.ts @@ -0,0 +1,104 @@ +/** + * RFC-0044 §3: how the authorization server fetches a client's metadata document (and, for a + * `private_key_jwt` client, its `jwks_uri`). The `cimd()` plugin needs a transport that resolves + * the host once, refuses special-use addresses and refuses redirects; Cloudhal already has one, + * the SSRF-safe fetcher of media imports (`safeFetch`, RFC-0004 §5.7), so this adapter puts it + * behind the plugin's `fetch`-shaped interface: + * + * - Node (`cloudhal studio`): `nodeTransport()` from `@cloudhal/core-ts/media-node` resolves every + * A/AAAA record, refuses the name if any address is not public, and pins the TLS connection to + * the vetted address. + * - Workers: `workersTransport()` from `@cloudhal/core-ts/media` (DoH checks; Workers cannot pin, + * and their `fetch` has no route to private networks), with the deployment's own hosts in + * `blockedHosts`. + * + * Either way: https on port 443 only, DNS names only (no IP literals), no internal names, no + * redirects followed, at most `CIMD_MAX_BYTES`, `CIMD_TIMEOUT_MS` in all, GET only, no request + * headers from the plugin forwarded except `Accept` (so no conditional revalidation: every refetch + * is a full 200 the plugin validates again). The plugin itself checks the JSON media type, the + * size again, `client_id` = URL, the MCP profile and the cache headers this adapter passes back. + * + * Worker-safe: no Node imports (the Node transport is injected by the runtime). + */ +import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider"; +import { type FetchTransport, safeFetch } from "@cloudhal/core-ts/media"; + +/** RFC-0044 §3: "5 KB limit, 5 s timeout" (the plugin's own limits are the same). */ +export const CIMD_MAX_BYTES = 5 * 1024; +export const CIMD_TIMEOUT_MS = 5_000; + +/** Response headers the plugin reads (media type and HTTP caching); nothing else is passed on. */ +const PASSED_HEADERS = [ + "content-type", + "cache-control", + "vary", + "expires", + "date", + "age", + "etag", + "last-modified", +] as const; + +export interface CimdFetchOptions { + /** The SSRF-safe transport: `nodeTransport()` on Node, `workersTransport()` on Workers. */ + readonly transport: FetchTransport; + /** + * This deployment's own hosts, never fetched (its Studio, every site's domains); a function is + * asked once per fetch, so the list follows domain changes without a read on other requests. + */ + readonly blockedHosts?: readonly string[] | (() => Promise) | undefined; + /** Whole fetch (default `CIMD_TIMEOUT_MS`); tests shorten it. */ + readonly timeoutMs?: number | undefined; + /** Test seam: which resolved addresses count as public (default: `isPublicAddress`). */ + readonly isAllowedAddress?: ((address: string) => boolean) | undefined; +} + +function urlOf(input: Parameters[0]): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** Rejects as soon as `signal` aborts (the plugin's own 5 s timer), else follows `p`. */ +function untilAborted(p: Promise, signal: AbortSignal | null | undefined): Promise { + if (!signal) return p; + if (signal.aborted) return Promise.reject(new Error("aborted")); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("aborted")); + signal.addEventListener("abort", onAbort, { once: true }); + p.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); +} + +/** The plugin's `fetchClientMetadataResource` over `safeFetch`. */ +export function cimdFetch(options: CimdFetchOptions): ClientMetadataResourceFetch { + const timeoutMs = options.timeoutMs ?? CIMD_TIMEOUT_MS; + return async (input, init) => { + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + if (method !== "GET") throw new TypeError("client metadata is fetched with GET only"); + const blocked = options.blockedHosts; + const blockedHosts = typeof blocked === "function" ? await blocked() : blocked; + const result = await untilAborted( + safeFetch(urlOf(input), { + transport: options.transport, + maxBytes: CIMD_MAX_BYTES, + // The plugin checks the JSON media type itself (`application/json` or `+json`). + accept: [], + maxRedirects: 0, + headersTimeoutMs: timeoutMs, + totalTimeoutMs: timeoutMs, + ...(blockedHosts ? { blockedHosts } : {}), + ...(options.isAllowedAddress ? { isAllowedAddress: options.isAllowedAddress } : {}), + }), + init?.signal, + ); + const headers = new Headers(); + for (const name of PASSED_HEADERS) { + const value = result.headers.get(name); + if (value !== null) headers.set(name, value); + } + return new Response(new Uint8Array(result.bytes), { status: 200, headers }); + }; +} diff --git a/packages/api/src/auth/dcr-server.ts b/packages/api/src/auth/dcr-server.ts new file mode 100644 index 00000000..fddb5a17 --- /dev/null +++ b/packages/api/src/auth/dcr-server.ts @@ -0,0 +1,64 @@ +/** + * RFC-0044 §3: `OauthServer.register` when DCR is on: Cloudhal's checks (`dcr.ts`), then the + * provider's `registerOAuthClient` called server-side with empty headers (no session, no initial + * access token: an anonymous public client). A provider refusal becomes a `RegistrationError` + * with the provider's RFC 7591 error body. + */ +import { APIError } from "better-auth/api"; +import { RegistrationError, registerDcrClient } from "./dcr.ts"; +import type { AuthDeps } from "./deps.ts"; +import type { McpOauthServerOptions } from "./oauth.ts"; +import { dcrLimits } from "./oauth-registration.ts"; +import type { OauthServer } from "./types.ts"; + +type RegisterApi = { + registerOAuthClient(input: { + body: Record; + headers: Headers; + }): Promise>; +}; + +/** The `register` member of `OauthServer`, or nothing with DCR off. */ +export function oauthClientRegistration( + d: AuthDeps, + options: McpOauthServerOptions, +): Pick { + const registration = options.registration; + if (registration?.dcr !== true) return {}; + const api = d.instance.api as unknown as RegisterApi; + const register = async (body: Record) => { + try { + return await api.registerOAuthClient({ body, headers: new Headers() }); + } catch (e) { + if (e instanceof APIError) { + const b = (e.body ?? {}) as { + error?: unknown; + error_description?: unknown; + message?: unknown; + }; + const error = typeof b.error === "string" ? b.error : "invalid_client_metadata"; + const description = + typeof b.error_description === "string" + ? b.error_description + : typeof b.message === "string" + ? b.message + : "the client metadata was refused"; + throw new RegistrationError(400, error, description); + } + throw e; + } + }; + return { + register: (input) => + registerDcrClient( + { + sql: d.sql, + staff: d.staffStore, + limits: dcrLimits(registration), + clientHosts: registration.clientHosts, + register, + }, + input, + ), + }; +} diff --git a/packages/api/src/auth/dcr.ts b/packages/api/src/auth/dcr.ts new file mode 100644 index 00000000..43985c25 --- /dev/null +++ b/packages/api/src/auth/dcr.ts @@ -0,0 +1,140 @@ +/** + * RFC-0044 §3: dynamic client registration (RFC 7591), the limited fallback for clients without a + * Client ID Metadata Document (Cursor). The endpoint is anonymous, so Cloudhal checks everything + * before the provider sees the request, then registers the client server-side with a clean body + * (no cookie, no `Authorization`, so never as a member and never with an initial access token): + * + * 1. rate limits in `rate_limits` (#173): 10 per client address per hour and 300 per installation + * per day, counted for every attempt, valid or not; + * 2. at most 5,000 registered clients (503 beyond it), pruned after 30 unused days; + * 3. the metadata: `client_name` 1–100 characters; `redirect_uris` 1–10, each https or loopback + * http (and on `CLOUDHAL_MCP_CLIENT_HOSTS` when set); public clients only + * (`token_endpoint_auth_method` `none`, also when absent: no secret is ever minted); + * `grant_types` ⊆ {`authorization_code`, `refresh_token`} with `authorization_code`; + * `response_types` = [`code`]. Only these fields, `application_type` and `scope` reach the + * provider: logos, URIs, contacts, JWKS and software statements are dropped, never stored. + * + * Errors are RFC 7591 §3.2.2 bodies (`invalid_client_metadata`, `invalid_redirect_uri`). + */ + +import type { SqlDriver } from "@cloudhal/storage"; +import type { StaffStore } from "../staff.ts"; +import { countDcrClients, type DcrLimits } from "./oauth-registration.ts"; +import { redirectUrisProblem } from "./redirect-policy.ts"; + +/** RFC-0044 §3: "Body ≤ 5 KB". */ +export const DCR_MAX_BODY_BYTES = 5 * 1024; +const NAME_MAX = 100; +const GRANTS: ReadonlySet = new Set(["authorization_code", "refresh_token"]); + +/** A refusal with its HTTP status and RFC 7591 / RFC 6749 error body. */ +export class RegistrationError extends Error { + override readonly name = "RegistrationError"; + readonly status: 400 | 413 | 429 | 503; + readonly error: string; + constructor(status: RegistrationError["status"], error: string, description: string) { + super(description); + this.status = status; + this.error = error; + } + body(): { error: string; error_description: string } { + return { error: this.error, error_description: this.message }; + } +} + +const invalid = (description: string) => + new RegistrationError(400, "invalid_client_metadata", description); + +/** The body the provider registers: the checked fields only. */ +export function registrationBody( + raw: unknown, + clientHosts: readonly string[] | undefined, +): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw invalid("the body must be a JSON object"); + const m = raw as Record; + const name = typeof m.client_name === "string" ? m.client_name.trim() : ""; + if (name.length === 0 || name.length > NAME_MAX) + throw invalid(`client_name is required (1–${NAME_MAX} characters)`); + const redirect = redirectUrisProblem(m.redirect_uris, clientHosts); + if (redirect) throw new RegistrationError(400, "invalid_redirect_uri", redirect); + const auth = m.token_endpoint_auth_method ?? "none"; + if (auth !== "none") + throw invalid("only public clients may register here (token_endpoint_auth_method none)"); + const grants = m.grant_types === undefined ? ["authorization_code"] : m.grant_types; + if (!Array.isArray(grants) || !grants.every((g) => typeof g === "string")) + throw invalid("grant_types must be a list of strings"); + // Clients send one grant union to every server: keep what this server issues. + const kept = [...new Set(grants.filter((g) => GRANTS.has(g)))]; + if (!kept.includes("authorization_code")) + throw invalid("grant_types must include authorization_code"); + if ( + m.response_types !== undefined && + !( + Array.isArray(m.response_types) && + m.response_types.length === 1 && + m.response_types[0] === "code" + ) + ) + throw invalid('response_types must be ["code"]'); + if ( + m.application_type !== undefined && + m.application_type !== "web" && + m.application_type !== "native" + ) + throw invalid("application_type must be web or native"); + if (m.scope !== undefined && (typeof m.scope !== "string" || m.scope.length > 1000)) + throw invalid("scope must be a space-separated string"); + return { + client_name: name, + redirect_uris: [...(m.redirect_uris as string[])], + token_endpoint_auth_method: "none", + grant_types: kept, + response_types: ["code"], + ...(m.application_type !== undefined ? { application_type: m.application_type } : {}), + ...(typeof m.scope === "string" && m.scope.trim() ? { scope: m.scope.trim() } : {}), + }; +} + +export interface DcrDeps { + readonly sql: SqlDriver; + readonly staff: StaffStore; + readonly limits: DcrLimits; + readonly clientHosts?: readonly string[] | undefined; + /** The provider's server-side `registerOAuthClient` (no HTTP, no session). */ + readonly register: (body: Record) => Promise>; +} + +/** Registers one public client for an anonymous caller at `address`; RFC 7591's response body. */ +export async function registerDcrClient( + d: DcrDeps, + input: { readonly text: string; readonly address: string }, +): Promise> { + const { limits } = d; + if ( + !(await d.staff.hit(`cloudhal:dcr:addr:${input.address}`, 3_600_000, limits.perAddressPerHour)) + ) + throw new RegistrationError(429, "rate_limited", "too many registrations; try again later"); + if (!(await d.staff.hit("cloudhal:dcr:day", 86_400_000, limits.perDay))) + throw new RegistrationError( + 429, + "rate_limited", + "this server takes no more registrations today; try again tomorrow", + ); + if (new TextEncoder().encode(input.text).byteLength > DCR_MAX_BODY_BYTES) + throw new RegistrationError(413, "invalid_client_metadata", "the metadata is larger than 5 KB"); + let raw: unknown; + try { + raw = JSON.parse(input.text); + } catch { + throw invalid("the body must be JSON"); + } + const body = registrationBody(raw, d.clientHosts); + if ((await countDcrClients(d.sql)) >= limits.maxClients) + throw new RegistrationError( + 503, + "temporarily_unavailable", + "this server has too many registered clients; try again later", + ); + return d.register(body); +} diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 8ebf91f3..73abe49b 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -7,6 +7,7 @@ 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 { metadataDocumentAllowed } from "./oauth-cimd-policy.ts"; import { oauthBeforeHook } from "./oauth-hooks.ts"; import { API_KEY_HEADER, @@ -72,9 +73,9 @@ export function createInstance( // 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, { - fromServer: (headers) => - constantTimeEqual(headers.get(SERVER_KEY_HEADER) ?? "", internal.serverKey), grantOf: async (userId, clientId) => (await internal.grants.live(userId, clientId))?.id, + metadataDocumentAllowed: (url) => + metadataDocumentAllowed(staff, url, options.mcpOauth?.registration?.clientHosts), }) : []; const instance = betterAuth({ @@ -110,6 +111,10 @@ export function createInstance( throw new APIError("FORBIDDEN", { message: "consent is given on Studio's screen" }); return; } + // RFC-0044 §3: clients register through Cloudhal's checked route, which calls the + // provider server-side (no request); the provider's endpoint itself is never reached. + if (ctx.path === "/oauth2/register" && ctx.request) + throw new APIError("NOT_FOUND", { message: "not found" }); // 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 as never, { grants: internal.grants, staff, now: clock }); @@ -198,10 +203,7 @@ export function createInstance( /** 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`). - */ + /** Marks the server's own call to the provider: the consent `AgentGrantService.consent` forwards. */ readonly serverKey: string; /** The `agent_grants` rows (RFC-0044 §5): the token claim and the authorize checks read them. */ readonly grants: GrantStore; diff --git a/packages/api/src/auth/oauth-cimd-policy.ts b/packages/api/src/auth/oauth-cimd-policy.ts new file mode 100644 index 00000000..d54f3f82 --- /dev/null +++ b/packages/api/src/auth/oauth-cimd-policy.ts @@ -0,0 +1,45 @@ +/** + * RFC-0044 §3: the operator's policy before a Client ID Metadata Document is fetched (the + * plugin's `isMetadataDocumentUrlAllowed`; the transport enforces the network rules). + * + * - `CLOUDHAL_MCP_CLIENT_HOSTS`: when set, the document's host must be on it. + * - A fetch budget across isolates in `rate_limits` (#173): 30 per client origin and 120 in all per + * minute. The plugin's own budget lives in one plugin instance, which a Worker rebuilds on every + * request, so it cannot bound what anonymous `/authorize` calls make the server fetch. + */ +import { APIError } from "better-auth/api"; +import type { StaffStore } from "../staff.ts"; +import { CIMD_FETCH_LIMITS } from "./oauth-registration.ts"; +import { hostMatches } from "./redirect-policy.ts"; + +export async function metadataDocumentAllowed( + staff: StaffStore, + url: string, + clientHosts: readonly string[] | undefined, +): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if ( + clientHosts && + clientHosts.length > 0 && + !clientHosts.some((p) => hostMatches(parsed.hostname, p)) + ) + return false; + const perOrigin = await staff.hit( + `cloudhal:cimd:origin:${parsed.origin}`, + 60_000, + CIMD_FETCH_LIMITS.perOriginPerMinute, + ); + const overall = + perOrigin && (await staff.hit("cloudhal:cimd:all", 60_000, CIMD_FETCH_LIMITS.perMinute)); + if (!overall) + throw new APIError("TOO_MANY_REQUESTS", { + error: "temporarily_unavailable", + error_description: "too many client metadata fetches; try again in a minute", + }); + return true; +} diff --git a/packages/api/src/auth/oauth-clients.ts b/packages/api/src/auth/oauth-clients.ts deleted file mode 100644 index 255c9310..00000000 --- a/packages/api/src/auth/oauth-clients.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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-registration.ts b/packages/api/src/auth/oauth-registration.ts new file mode 100644 index 00000000..4eeb0139 --- /dev/null +++ b/packages/api/src/auth/oauth-registration.ts @@ -0,0 +1,164 @@ +/** + * RFC-0044 §3 (Phase 3): how OAuth clients that were not created by the server get a + * `client_id`, and the operator's switches for it (§10: Worker vars, or the environment on Node). + * + * - **CIMD** (`CLOUDHAL_MCP_CIMD`, on unless `off`): a client names an HTTPS URL as its + * `client_id`; the server fetches the document there through the runtime's SSRF-safe transport + * (`cimd-fetch.ts`). `CLOUDHAL_MCP_CLIENT_HOSTS` limits which hosts may serve one. + * - **DCR** (`CLOUDHAL_MCP_DCR`, on unless `off`): RFC 7591 `POST /api/auth/oauth2/register`, + * public clients only, with rate limits, a cap and pruning (`dcr.ts`, `pruneDcrClients`). + * + * With both off, only clients already in the database can connect (none, unless an operator made + * one): an unknown `client_id` is refused at `/oauth2/authorize`. + */ +import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider"; +import type { SqlDriver } from "@cloudhal/storage"; + +export interface DcrLimits { + /** Registrations per client address per hour (§3: 10). */ + readonly perAddressPerHour: number; + /** Registrations per installation per day (§3: 300). */ + readonly perDay: number; + /** Dynamically registered clients that may exist at once (§3: 5,000; beyond it 503). */ + readonly maxClients: number; + /** A client nobody used for this many days is deleted by the hourly job (§3: 30). */ + readonly unusedDays: number; +} + +export const DCR_LIMITS: DcrLimits = { + perAddressPerHour: 10, + perDay: 300, + maxClients: 5_000, + unusedDays: 30, +}; + +/** + * Outbound metadata fetches across every isolate (Cloudhal's `rate_limits`, not the plugin's + * per-instance budget, which a Worker rebuilds on every request): per client origin and in all. + */ +export const CIMD_FETCH_LIMITS = { perOriginPerMinute: 30, perMinute: 120 } as const; + +export interface OauthRegistration { + /** CIMD's transport (`cimdFetch(...)`); absent: CIMD is off. */ + readonly cimd?: ClientMetadataResourceFetch | undefined; + /** Dynamic client registration; absent or false: `/register` is not served or advertised. */ + readonly dcr?: boolean | undefined; + /** + * `CLOUDHAL_MCP_CLIENT_HOSTS`: hosts a CIMD document may be served from, and hosts a DCR + * client may redirect to (`localhost` admits loopback redirects). Empty: any host. + */ + readonly clientHosts?: readonly string[] | undefined; + /** Overrides of `DCR_LIMITS` (tests). */ + readonly limits?: Partial | undefined; +} + +/** The variables both runtimes read (RFC-0044 §10). */ +export interface OauthRegistrationEnv { + readonly CLOUDHAL_MCP_CIMD?: string | undefined; + readonly CLOUDHAL_MCP_DCR?: string | undefined; + readonly CLOUDHAL_MCP_CLIENT_HOSTS?: string | undefined; +} + +const off = (value: string | undefined) => value?.trim().toLowerCase() === "off"; + +/** A DNS label: 1–63 of `a-z`, `0-9` and `-`, not starting or ending with `-`. */ +function isLabel(label: string): boolean { + if (label.length === 0 || label.length > 63) return false; + if (label.startsWith("-") || label.endsWith("-")) return false; + for (const c of label) + if (!((c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "-")) return false; + return true; +} + +/** A host pattern an operator may list: a DNS name, or `*.` and one. */ +function isHostPattern(pattern: string): boolean { + const name = pattern.startsWith("*.") ? pattern.slice(2) : pattern; + return name.length > 0 && name.length <= 253 && name.split(".").every(isLabel); +} + +/** `CLOUDHAL_MCP_CLIENT_HOSTS` as a list; throws on an entry that is not a host name. */ +export function parseClientHosts(value: string | undefined): string[] { + const hosts = (value ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + for (const h of hosts) + if (!isHostPattern(h)) + throw new Error(`CLOUDHAL_MCP_CLIENT_HOSTS: "${h}" is not a host name (or *.host)`); + return hosts; +} + +/** + * The registration a runtime runs, from its variables and its CIMD transport. A list of client + * hosts that does not parse turns registration off (fail closed) and says why in `problem`. + */ +export function oauthRegistrationFrom( + env: OauthRegistrationEnv, + cimd: ClientMetadataResourceFetch, +): OauthRegistration & { readonly problem?: string } { + let clientHosts: string[]; + try { + clientHosts = parseClientHosts(env.CLOUDHAL_MCP_CLIENT_HOSTS); + } catch (e) { + return { dcr: false, problem: e instanceof Error ? e.message : String(e) }; + } + return { + ...(off(env.CLOUDHAL_MCP_CIMD) ? {} : { cimd }), + dcr: !off(env.CLOUDHAL_MCP_DCR), + ...(clientHosts.length > 0 ? { clientHosts } : {}), + }; +} + +export function dcrLimits(registration: OauthRegistration | undefined): DcrLimits { + return { ...DCR_LIMITS, ...registration?.limits }; +} + +/** + * A dynamically registered client: no discovery source (CIMD sets `cimd`) and no owner (the + * provider records the member or reference only for registrations made with a session or an + * initial access token, which this server never accepts). + */ +export const DCR_CLIENT = `"clientDiscoveryId" IS NULL AND "userId" IS NULL AND "referenceId" IS NULL`; + +/** How many dynamically registered clients exist (the cap, §3). */ +export async function countDcrClients(sql: SqlDriver): Promise { + const row = await sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${DCR_CLIENT}`, + ); + return Number(row?.n ?? 0); +} + +/** The most clients one pruning pass deletes (D1 prefers short statements). */ +export const PRUNE_BATCH = 50; + +/** + * Deletes dynamically registered clients unused for `unusedDays` (§3 "Lifetime"): registered + * before the cutoff, no live connection, and no connection made, used or ended since the cutoff. + * A client with a live connection is never pruned, however idle: ending it is its member's + * decision ("Connected agents"). Every row the provider keeps for the client goes in the same + * atomic batch, each statement selecting the same clients (the predicate reads only + * `oauthClient` and `agent_grants`, which the batch changes last). Returns how many were deleted. + */ +export async function pruneDcrClients( + sql: SqlDriver, + options: { readonly now: Date; readonly unusedDays?: number }, +): Promise { + const days = options.unusedDays ?? DCR_LIMITS.unusedDays; + const cutoff = new Date(options.now.getTime() - days * 86_400_000).toISOString(); + const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE c."clientDiscoveryId" IS NULL AND c."userId" IS NULL AND c."referenceId" IS NULL AND c."createdAt" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."createdAt", c."clientId" LIMIT ${PRUNE_BATCH}`; + const params = [cutoff, cutoff, cutoff, cutoff]; + const code = (path: string) => + `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; + const results = await sql.batch([ + { sql: `DELETE FROM "oauthAccessToken" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthRefreshToken" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthConsent" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthClientResource" WHERE "clientId" IN (${prunable})`, params }, + { + sql: `DELETE FROM "verification" WHERE "value" LIKE '%authorization_code%' AND ${code("$.type")} = 'authorization_code' AND ${code("$.query.client_id")} IN (${prunable})`, + params, + }, + { sql: `DELETE FROM "oauthClient" WHERE "clientId" IN (${prunable})`, params }, + ]); + return results.at(-1)?.changes ?? 0; +} diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts index 698687ff..403102d5 100644 --- a/packages/api/src/auth/oauth.ts +++ b/packages/api/src/auth/oauth.ts @@ -10,15 +10,22 @@ * - 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. + * - Registration (Phase 3, `oauth-registration.ts`): Client ID Metadata Documents through + * `cimd()` and the runtime's SSRF-safe transport, and dynamic registration, which Cloudhal + * checks and limits first (`dcr.ts`) and then hands to the provider server-side. Redirect URIs + * are matched exactly (loopback without the port, RFC 8252 §7.3) and must be https or loopback + * http (`redirect-policy.ts`). No member creates or edits clients: the session-backed client + * CRUD endpoints are refused here and fenced in `createApi`. * * Worker-safe: the plugins and `jose` use WebCrypto and no `eval`. */ +import { cimd } from "@better-auth/cimd"; 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"; +import type { OauthRegistration } from "./oauth-registration.ts"; +import { redirectProblem } from "./redirect-policy.ts"; /** `offline_access` asks for a refresh token (the plugin issues one only then). */ export const OFFLINE_ACCESS = "offline_access"; @@ -49,6 +56,8 @@ export interface McpOauthServerOptions { * only (the plugin refuses anything else). */ readonly resource: string; + /** RFC-0044 §3 (Phase 3): CIMD and DCR. Absent: neither; only clients already stored connect. */ + readonly registration?: OauthRegistration | undefined; } /** @@ -60,6 +69,7 @@ export interface McpOauthServerOptions { export function mcpOauthFor( baseURL: string | undefined, setting?: string | undefined, + registration?: OauthRegistration | undefined, ): { readonly mcpOauth: McpOauthServerOptions } | { readonly off: string } { if (setting?.trim().toLowerCase() === "off") return { off: "CLOUDHAL_MCP_OAUTH=off" }; if (!baseURL) @@ -70,7 +80,8 @@ export function mcpOauthFor( 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 } }; + const resource = oauthEndpoints(url.origin).resource; + return { mcpOauth: { resource, ...(registration ? { registration } : {}) } }; } /** Issuer and resource of a deployment, both derived from its public base URL (§10). */ @@ -88,7 +99,9 @@ export function oauthPlugins( options: McpOauthServerOptions, hooks: OauthServerHooks, ): BetterAuthPlugin[] { - const { fromServer, grantOf } = hooks; + const { grantOf, metadataDocumentAllowed } = hooks; + const registration = options.registration; + const dcr = registration?.dcr === true; return [ jwt({ jwks: { keyPairConfig: { alg: ACCESS_TOKEN_ALG, crv: "Ed25519" } }, @@ -109,13 +122,19 @@ export function oauthPlugins( 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), + // §3: DCR is anonymous and public-only. The HTTP endpoint is Cloudhal's (`dcr.ts` checks + // and limits the request, then calls the provider server-side without a session); the + // provider's own route stays fenced. A loopback redirect makes the client `native`. + allowDynamicClientRegistration: dcr, + allowUnauthenticatedClientRegistration: dcr, + clientRegistrationDefaultApplicationType: "infer", + // No member creates, edits or links clients through the session-backed endpoints. + clientPrivileges: () => false, + resourcePrivileges: () => false, + // §6 "Open redirect": the registered URI must match (loopback without the port) and be + // https or loopback http, whichever way the client registered. + validateRedirectUri: (uri, _registered, matched) => + matched && redirectProblem(uri) === undefined, // 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) }, @@ -133,15 +152,32 @@ export function oauthPlugins( }, ], }), + // §3: CIMD with the runtime's transport and the MCP 2026-07-28 profile (`client_name` and + // `redirect_uris` required). Each plugin instance keeps its own bounded cache (per process on + // Node; per request on Workers, where nothing async may outlive a request). + ...(registration?.cimd + ? [ + cimd({ + fetchClientMetadataResource: registration.cimd, + metadataProfile: "mcp-2026-07-28", + metadataRevalidationInterval: "60m", + maxCacheEntries: 500, + isMetadataDocumentUrlAllowed: (url) => metadataDocumentAllowed(url), + }), + ] + : []), ]; } /** 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; + /** + * Before a metadata document is fetched: the host allow-list and the installation-wide fetch + * budget (`rate_limits`). False refuses the `client_id`; a 429 error refuses for now. + */ + readonly metadataDocumentAllowed: (url: string) => Promise; } /** The access-token claim that names the grant (RFC-0044 §5; a private claim name, RFC 7519 §4.3). */ diff --git a/packages/api/src/auth/redirect-policy.ts b/packages/api/src/auth/redirect-policy.ts new file mode 100644 index 00000000..834f0661 --- /dev/null +++ b/packages/api/src/auth/redirect-policy.ts @@ -0,0 +1,93 @@ +/** + * RFC-0044 §3 and §6 ("Open redirect"): which redirect URIs an OAuth client may use, for both + * registration paths (a Client ID Metadata Document and dynamic registration). + * + * - `https://` on a host that is not loopback, or `http://` on exactly `localhost`, `127.0.0.1` or + * `[::1]` (RFC 8252 §7.3; the port is free there: the provider matches loopback redirects + * without the port, §8.3). Nothing else: no private-use schemes, no plain HTTP elsewhere, no + * fragment, no userinfo, at most 2,048 characters. + * - With `CLOUDHAL_MCP_CLIENT_HOSTS` set (`clientHosts`), a dynamically registered client's + * redirect host must be on it (`claude.ai` or `*.example.com`); a loopback redirect then needs + * `localhost` on the list. CIMD clients are matched on their document's host instead. + * + * Linear checks only (AGENTS.md rule 13): the URL parser, `indexOf` and comparisons. + */ + +/** RFC-0044 §3: the most redirect URIs one client may register. */ +export const MAX_REDIRECT_URIS = 10; +const MAX_URI_LENGTH = 2048; +const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** The host part of an `http://` URI as written (the parser would normalise `127.1`). */ +function rawHttpHost(uri: string): string | undefined { + if (uri.slice(0, 7).toLowerCase() !== "http://") return undefined; + const rest = uri.slice(7); + let end = rest.length; + for (const c of ["/", "?", "#"]) { + const at = rest.indexOf(c); + if (at >= 0 && at < end) end = at; + } + const authority = rest.slice(0, end); + if (authority.startsWith("[")) { + const close = authority.indexOf("]"); + return close < 0 ? undefined : authority.slice(0, close + 1).toLowerCase(); + } + const colon = authority.indexOf(":"); + return (colon < 0 ? authority : authority.slice(0, colon)).toLowerCase(); +} + +/** True for a host pattern match: `example.com` exactly, `*.example.com` any subdomain. */ +export function hostMatches(host: string, pattern: string): boolean { + const h = host.toLowerCase(); + const p = pattern.toLowerCase(); + if (p.startsWith("*.")) { + const suffix = p.slice(1); + return h.length > suffix.length && h.endsWith(suffix); + } + return h === p; +} + +/** Why a redirect URI is refused, or `undefined` when it may be used. */ +export function redirectProblem( + uri: string, + clientHosts?: readonly string[] | undefined, +): string | undefined { + if (uri.length > MAX_URI_LENGTH) return "a redirect URI is longer than 2,048 characters"; + let url: URL; + try { + url = new URL(uri); + } catch { + return `${uri.slice(0, 100)} is not an absolute URI`; + } + if (uri.includes("#")) return "a redirect URI must not have a fragment"; + if (url.username || url.password) return "a redirect URI must not carry credentials"; + const loopback = url.protocol === "http:" && LOOPBACK_HOSTS.has(rawHttpHost(uri) ?? ""); + if (url.protocol === "http:" && !loopback) + return `http redirect URIs are allowed on localhost, 127.0.0.1 and [::1] only: ${url.host}`; + if (url.protocol === "https:" && LOOPBACK_HOSTS.has(url.hostname)) + return "a loopback redirect URI uses http (RFC 8252 §7.3)"; + if (url.protocol !== "http:" && url.protocol !== "https:") + return `redirect URIs use https (or http on loopback), not ${url.protocol}`; + if (clientHosts && clientHosts.length > 0) { + const host = loopback ? "localhost" : url.hostname; + if (!clientHosts.some((p) => hostMatches(host, p))) + return `${host} is not on this server's list of client hosts (CLOUDHAL_MCP_CLIENT_HOSTS)`; + } + return undefined; +} + +/** Why a client's redirect URIs are refused as a whole (1–10, each allowed), or `undefined`. */ +export function redirectUrisProblem( + uris: unknown, + clientHosts?: readonly string[] | undefined, +): string | undefined { + if (!Array.isArray(uris) || uris.length === 0) return "redirect_uris is required"; + if (uris.length > MAX_REDIRECT_URIS) + return `at most ${MAX_REDIRECT_URIS} redirect URIs may be registered`; + for (const uri of uris) { + if (typeof uri !== "string") return "redirect_uris must be strings"; + const problem = redirectProblem(uri, clientHosts); + if (problem) return problem; + } + return undefined; +} diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 9c4871c5..421bc2a1 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -156,16 +156,15 @@ export interface OauthServer { /** 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. + * RFC-0044 §3, with DCR on: registers a public client for an anonymous caller (the body as sent, + * the caller's address for the rate limit); throws `RegistrationError`. Absent with DCR off. */ - readonly createClient: (input: OauthClientInput) => Promise<{ clientId: string }>; -} - -export interface OauthClientInput { - readonly headers: Headers; - readonly name: string; - readonly redirectUris: readonly string[]; + readonly register?: + | ((input: { + readonly text: string; + readonly address: string; + }) => Promise>) + | undefined; } export interface AgentKeySummary { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fa2aae27..51787671 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -42,6 +42,7 @@ export { export type { ActorResolver, ApiDeps } from "./context.ts"; export type { ApiErrorBody } from "./errors.ts"; export { MailError, type MailMessage, type MailSender } from "./mail.ts"; +export { oauthClientsPruneJob } from "./oauth-prune-job.ts"; export { isBlockedInPlayground, PLAYGROUND_ALLOW, diff --git a/packages/api/src/oauth-prune-job.ts b/packages/api/src/oauth-prune-job.ts new file mode 100644 index 00000000..d160046d --- /dev/null +++ b/packages/api/src/oauth-prune-job.ts @@ -0,0 +1,25 @@ +/** + * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes dynamically registered + * OAuth clients nobody used for 30 days (`pruneDcrClients`), on the admin Worker's tick and on + * Node's (`cloudhal studio`, `dev`, `mcp`). It runs whether DCR is on or off: clients registered + * while it was on still age out. Bounded: at most `PRUNE_BATCH` clients per run, one batch. + */ +import { hourlyAt, type SystemJobDefinition } from "@cloudhal/schedules"; +import type { SqlDriver } from "@cloudhal/storage"; +import { pruneDcrClients } from "./auth/oauth-registration.ts"; + +export function oauthClientsPruneJob( + sql: SqlDriver, + log: (line: string) => void, +): SystemJobDefinition { + return { + name: "oauth.clients.prune", + // One batch of six statements. + cost: 6, + next: hourlyAt(41), + async run({ now }) { + const deleted = await pruneDcrClients(sql, { now }); + if (deleted > 0) log(`oauth clients: ${deleted} unused registration(s) deleted`); + }, + }; +} diff --git a/packages/api/src/oauth-server.ts b/packages/api/src/oauth-server.ts index de0eef61..e042c2ea 100644 --- a/packages/api/src/oauth-server.ts +++ b/packages/api/src/oauth-server.ts @@ -7,26 +7,30 @@ * 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"); + * - §3 (Phase 3): Cloudhal's `POST /api/auth/oauth2/register` when DCR is on + * (`mountClientRegistration`, before Better Auth's catch-all); * - 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 { DCR_MAX_BODY_BYTES, RegistrationError } from "./auth/dcr.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"; +import { clientIp } from "./tokens.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. + * `/api/auth/oauth2/` (client and consent CRUD, introspection, userinfo) 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; registration through Cloudhal's route, open only with DCR on. */ const OPEN_OAUTH_PATHS = new Set([ "/api/auth/oauth2/authorize", @@ -34,8 +38,17 @@ const OPEN_OAUTH_PATHS = new Set([ "/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 { +/** RFC-0044 §3: where clients register (RFC 7591); Cloudhal's route, not the provider's. */ +export const REGISTER_PATH = "/api/auth/oauth2/register"; + +/** + * True for a Better Auth path of the authorization server that HTTP callers may not reach. + * `register`: DCR is on, so `REGISTER_PATH` is open (Cloudhal's route answers it). + */ +export function fencedOauthPath(pathname: string, open: { register?: boolean } = {}): boolean { + // Exactly this path: a variant (a trailing slash) would miss Cloudhal's route and reach the + // provider's own endpoint through Better Auth's catch-all. + if (pathname === REGISTER_PATH) return !open.register; 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 @@ -56,6 +69,67 @@ export function mountAuthorizationServerMetadata(app: Hono, server: OauthSe app.get(AUTHORIZATION_SERVER_PATH, handler); } +const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" } as const; + +/** The body as text, at most `max + 1` bytes read (so an oversized body is refused unread). */ +async function cappedText(req: Request, max: number): Promise { + const reader = req.body?.getReader(); + if (!reader) return ""; + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + size += value.byteLength; + if (size > max) { + await reader.cancel().catch(() => {}); + break; + } + } + const bytes = new Uint8Array(size); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.byteLength; + } + return new TextDecoder().decode(bytes); +} + +/** + * RFC-0044 §3: `POST /api/auth/oauth2/register` (RFC 7591, `ROUTE_SCOPES` public as all of + * `/api/auth/*`), mounted before Better Auth's catch-all. JSON only; 201 with the client, or the + * RFC 7591 error body; never cached. + */ +export function mountClientRegistration( + app: Hono, + register: NonNullable, +): void { + app.post(REGISTER_PATH, async (c) => { + const type = (c.req.header("content-type") ?? "").split(";")[0]?.trim().toLowerCase(); + if (type !== "application/json") + return c.json( + { + error: "invalid_client_metadata", + error_description: "send the metadata as application/json", + }, + 400, + NO_STORE, + ); + const declared = Number(c.req.header("content-length") ?? 0); + const text = + declared > DCR_MAX_BODY_BYTES + ? "x".repeat(DCR_MAX_BODY_BYTES + 1) + : await cappedText(c.req.raw, DCR_MAX_BODY_BYTES); + try { + return c.json(await register({ text, address: clientIp(c) }), 201, NO_STORE); + } catch (e) { + if (e instanceof RegistrationError) return c.json(e.body(), e.status, NO_STORE); + throw e; + } + }); +} + /** RFC-0044 Phase 2: the metadata documents and `/api/agent-grants`. */ export function mountOauthServer(app: Hono, server: OauthServer): void { mountAuthorizationServerMetadata(app, server); diff --git a/packages/api/src/testing-mcp-oauth-registration.ts b/packages/api/src/testing-mcp-oauth-registration.ts new file mode 100644 index 00000000..f420c4c3 --- /dev/null +++ b/packages/api/src/testing-mcp-oauth-registration.ts @@ -0,0 +1,444 @@ +/** + * Shared suite for RFC-0044 Phase 3a (§3, §6, §8): how clients get a `client_id`, on + * better-sqlite3 and Miniflare D1. + * + * - CIMD with a stubbed transport (`cimdFetch` over `safeFetch`, the media import's SSRF rules): a + * good document connects all the way to `/mcp`; a wrong `client_id`, a private address, an + * oversized document, a redirect, a timeout and a host off the allow-list are refused; the + * metadata advertises `client_id_metadata_document_supported`. + * - DCR: the full flow to `/mcp`; the metadata rules (public only, redirect URIs, size); the rate + * limits per address and per day; the cap; pruning; the off switch; the allow-list. + * - Loopback redirects match without the port (RFC 8252 §7.3), nothing else does. + * - With CIMD and DCR off, an unknown client is refused. + */ +import type { FetchTransport } from "@cloudhal/core-ts/media"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { cimdFetch } from "./auth/cimd-fetch.ts"; +import { type OauthRegistration, pruneDcrClients } from "./auth/oauth-registration.ts"; +import { redirectProblem } from "./auth/redirect-policy.ts"; +import { oauthClientsPruneJob } from "./oauth-prune-job.ts"; +import { + authorize, + connect, + ISSUER, + type Json, + mcpClient, + REDIRECT, + registerClient, + type Server, + setupServer, +} from "./testing-oauth-harness.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +const READ = "site:read content:read"; +const DAY = 86_400_000; +/** A client metadata document URL (RFC-0044 §3: HTTPS with a path). */ +const DOC = "https://agents.example.com/oauth/client.json"; +/** A public address the stub resolver answers (TEST-NET-3 is not public: use a real range). */ +const PUBLIC = "93.184.215.14"; + +interface Served { + readonly status?: number; + readonly headers?: Record; + readonly body?: string; + /** Never answers (the timeout case). */ + readonly hang?: boolean; +} + +/** A transport that serves documents from the test and records every request. */ +function stubTransport( + serve: (url: URL) => Served, + addresses: readonly string[] = [PUBLIC], +): FetchTransport & { readonly requests: string[] } { + const requests: string[] = []; + return { + name: "stub", + pins: true, + requests, + resolve: async () => [...addresses], + request: (url, init) => { + requests.push(url.toString()); + const answer = serve(url); + if (answer.hang) + return new Promise((_, reject) => + init.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }), + ); + return Promise.resolve({ + status: answer.status ?? 200, + headers: new Headers({ "content-type": "application/json", ...answer.headers }), + body: new Response(answer.body ?? "").body, + }); + }, + }; +} + +function document(overrides: Json = {}): string { + return JSON.stringify({ + client_id: DOC, + client_name: "Claude Code", + redirect_uris: ["http://127.0.0.1/callback", "http://localhost/callback"], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + ...overrides, + }); +} + +/** CIMD (and DCR, unless turned off) over `transport`, with a short timeout. */ +function withCimd(transport: FetchTransport, extra: Partial = {}) { + return { + registration: { + cimd: cimdFetch({ transport, blockedHosts: ["localhost"], timeoutMs: 200 }), + dcr: true, + ...extra, + } satisfies OauthRegistration, + }; +} + +/** `authorize` as the member's browser for another client: the consent screen, or the refusal. */ +async function tryAuthorize(s: Server, clientId: string, redirectUri = REDIRECT) { + const r = await authorize(s, { cookie: s.owner.cookie, scope: READ, clientId, redirectUri }); + const consentScreen = r.location?.pathname === "/oauth/consent"; + const text = consentScreen ? "" : await r.res.text(); + // A refusal is a JSON error, or the authorization server's error page (never the redirect URI). + const reason = r.location?.searchParams.get("error_description") ?? text; + return { ...r, consentScreen, status: r.res.status, text, reason }; +} + +const FETCH_FAILED = /Failed to fetch metadata document/; + +export function describeMcpOauthRegistration(label: string, backend: RolesBackend): void { + describe(`MCP OAuth client registration: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + afterEach(() => { + vi.useRealTimers(); + clearAccessTokenKeyCache(); + }); + + describe("Client ID Metadata Documents", () => { + it("connects a CIMD client all the way to /mcp, and advertises CIMD", async () => { + const transport = stubTransport(() => ({ + body: document(), + headers: { "cache-control": "max-age=600" }, + })); + const s = await setupServer(backend, withCimd(transport)); + const meta = (await s.call("GET", "/.well-known/oauth-authorization-server/api/auth")).body; + expect(meta.client_id_metadata_document_supported).toBe(true); + expect(meta.token_endpoint_auth_methods_supported).toContain("none"); + const client = { ...s, clientId: DOC }; + // RFC 8252 §7.3: the document registers no port; the client picks one at run time. + const { access } = await connect(client, { cookie: s.owner.cookie, scope: READ }); + await (await mcpClient(s.app, access)).listTools(); + expect(transport.requests[0]).toBe(DOC); + // The connection is the member's, named as the document names the client. + const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + expect(grants.find((g) => g.clientId === DOC)).toMatchObject({ clientName: "Claude Code" }); + }); + + it("refuses a document whose client_id is not its URL", async () => { + const transport = stubTransport(() => ({ + body: document({ client_id: "https://agents.example.com/other.json" }), + })); + const s = await setupServer(backend, withCimd(transport)); + const r = await tryAuthorize(s, DOC); + expect(r.consentScreen).toBe(false); + expect(r.reason).toContain("does not match the metadata document URL"); + }); + + it("never fetches from a private or loopback address, or an IP literal", async () => { + const privateDns = stubTransport(() => ({ body: document() }), [PUBLIC, "10.0.0.7"]); + const s = await setupServer(backend, withCimd(privateDns)); + expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED); + expect(privateDns.requests).toEqual([]); + const loopback = stubTransport(() => ({ body: document() }), ["127.0.0.1"]); + const t = await setupServer(backend, withCimd(loopback)); + expect((await tryAuthorize(t, DOC)).consentScreen).toBe(false); + expect(loopback.requests).toEqual([]); + const literal = "https://93.184.215.14/client.json"; + const any = stubTransport(() => ({ body: document({ client_id: literal }) })); + const u = await setupServer(backend, withCimd(any)); + expect((await tryAuthorize(u, literal)).consentScreen).toBe(false); + expect(any.requests).toEqual([]); + }); + + it("never fetches from the deployment's own hosts or its sites' domains", async () => { + const transport = stubTransport(() => ({ body: document() })); + const s = await setupServer(backend, { + registration: { + cimd: cimdFetch({ transport, blockedHosts: async () => ["agents.example.com"] }), + dcr: true, + }, + }); + expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED); + expect(transport.requests).toEqual([]); + }); + + it("refuses an oversized document, a redirect and a server that does not answer", async () => { + const big = stubTransport(() => ({ + body: document({ client_name: "x".repeat(6 * 1024) }), + })); + const s = await setupServer(backend, withCimd(big)); + expect((await tryAuthorize(s, DOC)).reason).toMatch(FETCH_FAILED); + const moved = stubTransport(() => ({ + status: 302, + headers: { location: "https://agents.example.com/elsewhere.json" }, + })); + const t = await setupServer(backend, withCimd(moved)); + expect((await tryAuthorize(t, DOC)).reason).toMatch(FETCH_FAILED); + expect(moved.requests).toEqual([DOC]); + const slow = stubTransport(() => ({ hang: true })); + const u = await setupServer(backend, withCimd(slow)); + const started = Date.now(); + expect((await tryAuthorize(u, DOC)).reason).toMatch(FETCH_FAILED); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + it("fetches only from allowed hosts when CLOUDHAL_MCP_CLIENT_HOSTS is set", async () => { + const transport = stubTransport(() => ({ body: document() })); + const s = await setupServer( + backend, + withCimd(transport, { clientHosts: ["claude.ai", "localhost"] }), + ); + expect((await tryAuthorize(s, DOC)).consentScreen).toBe(false); + expect(transport.requests).toEqual([]); + const t = await setupServer( + backend, + withCimd(transport, { clientHosts: ["*.example.com", "localhost"] }), + ); + expect((await tryAuthorize(t, DOC)).consentScreen).toBe(true); + }); + + it("uses a document's redirect URIs only as https or loopback http", async () => { + const transport = stubTransport(() => ({ + body: document({ + redirect_uris: ["http://127.0.0.1/callback", "cursor://anysphere.cursor/callback"], + }), + })); + const s = await setupServer(backend, withCimd(transport)); + expect((await tryAuthorize(s, DOC)).consentScreen).toBe(true); + expect( + (await tryAuthorize(s, DOC, "cursor://anysphere.cursor/callback")).consentScreen, + ).toBe(false); + }); + }); + + describe("dynamic client registration", () => { + const register = (s: Server, body: Json, headers: Record = {}) => + s.call("POST", "/api/auth/oauth2/register", { + body: { + client_name: "Cursor", + redirect_uris: ["http://localhost:8787/callback"], + ...body, + }, + headers, + }); + + it("registers a public client that connects to /mcp; the metadata names the endpoint", async () => { + const s = await setupServer(backend); + const meta = (await s.call("GET", "/.well-known/oauth-authorization-server")).body; + expect(meta.registration_endpoint).toBe(`${ISSUER}/oauth2/register`); + const res = await register(s, { redirect_uris: [REDIRECT] }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.res.headers.get("cache-control")).toBe("no-store"); + // Absent, the method is `none`: no secret is ever minted. + expect(res.body.token_endpoint_auth_method).toBe("none"); + expect(res.body.client_secret).toBeUndefined(); + const client = { ...s, clientId: res.body.client_id as string }; + const { access } = await connect(client, { cookie: s.owner.cookie, scope: READ }); + await (await mcpClient(s.app, access)).listTools(); + }); + + it("takes public clients with https or loopback redirect URIs only", async () => { + const s = await setupServer(backend, { + registration: { dcr: true, limits: { perAddressPerHour: 100 } }, + }); + const refused: [Json, string][] = [ + [{ token_endpoint_auth_method: "client_secret_basic" }, "invalid_client_metadata"], + [{ redirect_uris: ["http://cursor.example.com/callback"] }, "invalid_redirect_uri"], + [{ redirect_uris: ["cursor://anysphere.cursor/callback"] }, "invalid_redirect_uri"], + [{ redirect_uris: ["https://localhost/callback"] }, "invalid_redirect_uri"], + [{ redirect_uris: ["https://app.example.com/cb#x"] }, "invalid_redirect_uri"], + [{ redirect_uris: [] }, "invalid_redirect_uri"], + [ + { redirect_uris: Array.from({ length: 11 }, (_, i) => `https://a.example.com/${i}`) }, + "invalid_redirect_uri", + ], + [{ client_name: "" }, "invalid_client_metadata"], + [{ client_name: "x".repeat(101) }, "invalid_client_metadata"], + [{ grant_types: ["client_credentials"] }, "invalid_client_metadata"], + [{ response_types: ["token"] }, "invalid_client_metadata"], + [{ scope: "*" }, "invalid_scope"], + ]; + for (const [body, error] of refused) { + const r = await register(s, body); + expect(r.status, JSON.stringify(body)).toBe(400); + expect(r.body.error, JSON.stringify(body)).toBe(error); + } + const big = await register(s, { software_statement: "x".repeat(6 * 1024) }); + expect(big.status).toBe(413); + // Self-asserted URIs and keys are dropped, never stored or shown (RFC 7591 §5). + const ok = await register(s, { + logo_uri: "https://evil.example.com/logo.png", + jwks_uri: "https://evil.example.com/jwks", + grant_types: ["authorization_code", "refresh_token", "client_credentials"], + }); + expect(ok.status, JSON.stringify(ok.body)).toBe(201); + expect(ok.body.logo_uri).toBeUndefined(); + expect(ok.body.jwks_uri).toBeUndefined(); + expect(ok.body.grant_types).toEqual(["authorization_code", "refresh_token"]); + }); + + it("never reaches the provider's own endpoint over HTTP, even past createApi", async () => { + const s = await setupServer(backend); + const direct = await s.auth.handler( + new Request(`${ISSUER}/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_name: "x", redirect_uris: [REDIRECT] }), + }), + ); + expect(direct.status).toBe(404); + }); + + it("limits registrations per address per hour and per installation per day", async () => { + const s = await setupServer(backend, { + registration: { dcr: true, limits: { perAddressPerHour: 2, perDay: 4 } }, + }); + const from = (ip: string) => ({ "cf-connecting-ip": ip }); + expect((await register(s, {}, from("203.0.113.1"))).status).toBe(201); + // Invalid attempts count too. + expect((await register(s, { client_name: "" }, from("203.0.113.1"))).status).toBe(400); + const third = await register(s, {}, from("203.0.113.1")); + expect(third.status).toBe(429); + expect(third.body.error).toBe("rate_limited"); + expect((await register(s, {}, from("203.0.113.2"))).status).toBe(201); + // setupServer's own registration was the first of the day: this is the fifth. + expect((await register(s, {}, from("203.0.113.3"))).status).toBe(429); + }); + + it("refuses registrations beyond the cap with 503", async () => { + const s = await setupServer(backend, { + registration: { dcr: true, limits: { maxClients: 2 } }, + }); + expect((await register(s, {})).status).toBe(201); + const full = await register(s, {}); + expect(full.status).toBe(503); + expect(full.body.error).toBe("temporarily_unavailable"); + }); + + it("prunes clients nobody used for 30 days, never one with a live connection", async () => { + const s = await setupServer(backend); + // s.clientId: connected and live. `idle`: never connected. `ended`: connected, revoked. + await connect(s, { cookie: s.owner.cookie, scope: READ }); + const idle = await registerClient(s); + const ended = { ...s, clientId: await registerClient(s) }; + await connect(ended, { cookie: s.owner.cookie, scope: `${READ} offline_access` }); + const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + const endedGrant = grants.find((g) => g.clientId === ended.clientId); + await s.call("DELETE", `/api/agent-grants/${endedGrant?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + const clients = async () => + (await s.sql.all<{ clientId: string }>(`SELECT "clientId" FROM "oauthClient"`)).map( + (r) => r.clientId, + ); + // Within 30 days nothing goes. + expect(await pruneDcrClients(s.sql, { now: new Date(Date.now() + 29 * DAY) })).toBe(0); + // The scheduler's hourly job does it (Workers and Node), and says so. + const lines: string[] = []; + await oauthClientsPruneJob(s.sql, (l) => lines.push(l)).run({ + now: new Date(Date.now() + 31 * DAY), + trigger: "cron", + }); + expect(lines).toEqual(["oauth clients: 2 unused registration(s) deleted"]); + expect(await clients()).toEqual([s.clientId]); + const leftovers = await s.sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthRefreshToken" WHERE "clientId" = ?`, + [ended.clientId], + ); + expect(Number(leftovers?.n)).toBe(0); + // A pruned client is unknown: it registers again. + expect((await tryAuthorize(s, idle)).consentScreen).toBe(false); + }); + + it("is off with CLOUDHAL_MCP_DCR=off; with CIMD off too, unknown clients are refused", async () => { + const s = await setupServer(backend, { registration: { dcr: false } }); + const meta = (await s.call("GET", "/.well-known/oauth-authorization-server")).body; + expect(meta.registration_endpoint).toBeUndefined(); + expect(meta.client_id_metadata_document_supported).toBeUndefined(); + expect((await register(s, {})).status).toBe(404); + expect((await tryAuthorize(s, "made-up-client")).consentScreen).toBe(false); + expect((await tryAuthorize(s, DOC)).consentScreen).toBe(false); + }); + + it("takes only redirect hosts on CLOUDHAL_MCP_CLIENT_HOSTS when it is set", async () => { + // `localhost` on the list admits loopback redirects (setupServer registers one). + const t = await setupServer(backend, { + registration: { dcr: true, clientHosts: ["cursor.com", "localhost"] }, + }); + const to = (uri: string) => register(t, { redirect_uris: [uri] }); + expect((await to("https://www.cursor.com/agents/mcp/oauth/callback")).status).toBe(400); + expect((await to("https://claude.ai/api/mcp/auth_callback")).status).toBe(400); + expect((await to("https://cursor.com/agents/mcp/oauth/callback")).status).toBe(201); + expect((await to(REDIRECT)).status).toBe(201); + }); + }); + + describe("loopback redirect matching (RFC 8252 §7.3)", () => { + it("matches loopback redirects on any port, and nothing else loosely", async () => { + const s = await setupServer(backend); + const client = await registerClient(s, { redirect_uris: ["http://127.0.0.1/callback"] }); + for (const port of ["33418", "8080", ""]) { + const uri = `http://127.0.0.1${port ? `:${port}` : ""}/callback`; + expect((await tryAuthorize(s, client, uri)).consentScreen, uri).toBe(true); + } + for (const uri of [ + "http://localhost:33418/callback", + "http://127.0.0.1:33418/callback/other", + "http://127.0.0.1:33418/Callback", + "http://127.0.0.1:33418/callback?x=1", + "https://127.0.0.1:33418/callback", + ]) + expect((await tryAuthorize(s, client, uri)).consentScreen, uri).toBe(false); + const web = await registerClient(s, { redirect_uris: ["https://app.example.com/cb"] }); + expect((await tryAuthorize(s, web, "https://app.example.com/cb")).consentScreen).toBe(true); + expect((await tryAuthorize(s, web, "https://app.example.com:8443/cb")).consentScreen).toBe( + false, + ); + }); + + it("checks redirect URIs with linear rules", () => { + expect(redirectProblem("http://localhost:1/cb")).toBeUndefined(); + expect(redirectProblem("http://[::1]:1/cb")).toBeUndefined(); + expect(redirectProblem("https://claude.ai/api/mcp/auth_callback")).toBeUndefined(); + expect(redirectProblem("http://127.1/cb")).toBeDefined(); + expect(redirectProblem("http://localhost.evil.com/cb")).toBeDefined(); + expect(redirectProblem("http://user@localhost/cb")).toBeDefined(); + expect(redirectProblem(`https://a.com/${"a".repeat(100_000)}`)).toBeDefined(); + expect(redirectProblem("http://".padEnd(100_000, "["))).toBeDefined(); + expect(redirectProblem("https://x.cursor.com/cb", ["*.cursor.com"])).toBeUndefined(); + expect(redirectProblem("https://cursor.com/cb", ["*.cursor.com"])).toBeDefined(); + expect(redirectProblem("http://localhost:9/cb", ["claude.ai"])).toBeDefined(); + expect(redirectProblem("http://localhost:9/cb", ["localhost"])).toBeUndefined(); + }); + }); + + it("never takes a client-sent X-Forwarded-For as the address (#165)", async () => { + const s = await setupServer(backend, { + registration: { dcr: true, limits: { perAddressPerHour: 1 } }, + }); + // setupServer's registration used the shared bucket of requests without a trusted address. + const r = await s.call("POST", "/api/auth/oauth2/register", { + body: { client_name: "x", redirect_uris: [REDIRECT] }, + headers: { "x-forwarded-for": "198.51.100.9" }, + }); + expect(r.status).toBe(429); + }); + }); +} diff --git a/packages/api/src/testing-mcp-oauth-server.ts b/packages/api/src/testing-mcp-oauth-server.ts index 99a55c87..e5f3db5e 100644 --- a/packages/api/src/testing-mcp-oauth-server.ts +++ b/packages/api/src/testing-mcp-oauth-server.ts @@ -3,8 +3,8 @@ * 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. + * consent; scopes narrowed by the member's installation role. The client registers through DCR + * (`registerClient`); registration itself is `testing-mcp-oauth-registration.ts`'s. */ import { narrow, roleScopes, scopesForMode } from "@cloudhal/agent-runtime"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; @@ -67,8 +67,11 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo 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(); + // Phase 3: DCR is on in this deployment (the harness's default), CIMD is not. + expect(meta.registration_endpoint).toBe(`${ISSUER}/oauth2/register`); + expect(meta.client_id_metadata_document_supported).toBeUndefined(); + // §2: public clients and private_key_jwt only; no client holds a shared secret. + expect(meta.token_endpoint_auth_methods_supported).toEqual(["none", "private_key_jwt"]); // 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); @@ -190,7 +193,9 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo for (const path of [ "/api/auth/oauth2/consent", "/api/auth/oauth2/create-client", - "/api/auth/oauth2/register", + // Only Cloudhal's checked route registers: no variant reaches the provider's own. + "/api/auth/oauth2/register/", + "/api/auth/oauth2/%72egister", "/api/auth/oauth2/get-consents", "/api/auth/oauth2/introspect", "/api/auth/token", diff --git a/packages/api/src/testing-oauth-harness.ts b/packages/api/src/testing-oauth-harness.ts index 30d5e0ef..f4f5b62d 100644 --- a/packages/api/src/testing-oauth-harness.ts +++ b/packages/api/src/testing-oauth-harness.ts @@ -1,15 +1,16 @@ /** - * 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`. + * Test harness for RFC-0044: a deployment that runs the authorization server, a public client + * registered the way a client without a metadata document registers (DCR, `POST + * /api/auth/oauth2/register`; CIMD clients are `testing-mcp-oauth-registration.ts`'s), 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` and the registration suite. */ 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 { createAuth, type OauthRegistration } from "./auth.ts"; import type { RolesBackend } from "./testing-roles.ts"; export type Json = Record; @@ -29,6 +30,8 @@ export interface DeploymentOptions { readonly resource?: string; /** The trusted client-address header (`AuthOptions.clientAddressHeader`; turns rate limits on). */ readonly clientAddressHeader?: string; + /** CIMD and DCR (default: DCR on, CIMD off). */ + readonly registration?: OauthRegistration; } export type Deployment = Awaited>; @@ -95,7 +98,10 @@ export async function deploy( baseURL, secret: SECRET, signUp: "first-user", - mcpOauth: { resource: options.resource ?? `${new URL(baseURL).origin}/mcp` }, + mcpOauth: { + resource: options.resource ?? `${new URL(baseURL).origin}/mcp`, + registration: options.registration ?? { dcr: true }, + }, ...(options.clientAddressHeader ? { clientAddressHeader: options.clientAddressHeader } : {}), }); const server = auth.oauth; @@ -153,11 +159,10 @@ export async function setupServer(backend: RolesBackend, options: DeploymentOpti }); 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], - }); + const clientId = + options.registration?.dcr === false + ? "" + : await registerClient(d, { client_name: "Test Agent" }); /** A member who joined through an invite (RFC-0005 §7), signed in. */ /** `email` again: a removed member invited back keeps their account (RFC-0005 §7.4). */ const join = async (role: string, extra: Json = {}, again?: string) => { @@ -183,10 +188,28 @@ export async function setupServer(backend: RolesBackend, options: DeploymentOpti export type Server = Awaited>; +/** RFC 7591 registration as an MCP client sends it; the new `client_id`. */ +export async function registerClient(d: Deployment, metadata: Json = {}): Promise { + const res = await d.call("POST", "/api/auth/oauth2/register", { + body: { + client_name: "Test Agent", + redirect_uris: [REDIRECT], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + ...metadata, + }, + }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + return res.body.client_id as string; +} + export interface AuthorizeOptions { readonly cookie: string; readonly scope: string; readonly clientId?: string; + /** Default `REDIRECT`. */ + readonly redirectUri?: string; readonly challengeMethod?: string | null; readonly resource?: string; /** `POST`: the parameters as a form body (the provider accepts both). */ @@ -199,7 +222,7 @@ export async function authorize(s: Server, o: AuthorizeOptions) { const params = new URLSearchParams({ response_type: "code", client_id: o.clientId ?? s.clientId, - redirect_uri: REDIRECT, + redirect_uri: o.redirectUri ?? REDIRECT, scope: o.scope, state: "st4te", resource: o.resource ?? RESOURCE, @@ -255,9 +278,16 @@ export async function token(s: Server, form: Record) { /** 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[] }, + o: { + cookie: string; + scope: string; + mode?: string; + keep?: readonly string[]; + redirectUri?: string; + }, ) { - const start = await authorize(s, { cookie: o.cookie, scope: o.scope }); + const redirectUri = o.redirectUri ?? REDIRECT; + const start = await authorize(s, { cookie: o.cookie, scope: o.scope, redirectUri }); 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, { @@ -272,7 +302,7 @@ export async function connect( const tokens = await token(s, { grant_type: "authorization_code", code, - redirect_uri: REDIRECT, + redirect_uri: redirectUri, code_verifier: start.verifier, }); expect(tokens.status, JSON.stringify(tokens.body)).toBe(200); diff --git a/packages/api/src/testing-schedules.ts b/packages/api/src/testing-schedules.ts index aeb15c59..f6fabfff 100644 --- a/packages/api/src/testing-schedules.ts +++ b/packages/api/src/testing-schedules.ts @@ -24,6 +24,7 @@ import { requestRelease, type SchedulesDeps, } from "@cloudhal/mcp-server/schedules"; +import { defineTools } from "@cloudhal/mcp-server/tools"; import { SqlScheduleStore } from "@cloudhal/schedules/sql"; import { MemoryBlobStore, type SqlDriver } from "@cloudhal/storage"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; @@ -525,6 +526,39 @@ export function describeSchedulesApi(label: string, factory: SchedulesApiFactory }); }); + it("an OAuth connection's set_embargo waits for a person; approving puts it live (RFC-0044)", async () => { + const live = (await store.listContentEntries(site.id))[0] as ContentEntry; + const setEmbargo = defineTools().find((t) => t.name === "set_embargo"); + if (!setEmbargo) throw new Error("set_embargo"); + // A token principal: an agent with a grant and no key, the member behind it an Editor. + const editor = person("editor"); + const principal: Principal = { + actor: agent, + scopes: ["schedules:write", "content:write", "content:read", "site:read"], + mode: "edits", + grantId: "grant_desk00000001", + ...(editor.person ? { person: editor.person } : {}), + }; + const result = (await setEmbargo.handler( + { siteId: site.id, nodeId: live.id, from: plus(5 * HOUR), expectedRev: live.rev }, + { store, principal, schedules: deps, approvals, now: () => clock.now }, + )) as { status: string; approvalId: string; release: Release }; + expect(result.status).toBe("approval-pending"); + expect(result.release.state).toBe("awaiting_approval"); + // the people who may decide it are told (§14), the agent's own member included + const kinds = ((await call("/api/notifications", "editor")).body.items as Json[]).map( + (n) => n.kind, + ); + expect(kinds).toContain("schedule-approval-due"); + const approved = await post(`${S()}/approvals/${result.approvalId}/approve`, {}, "owner"); + expect(approved.status, approved.text).toBe(200); + expect(await schedules.getRelease(site.id, result.release.id)).toMatchObject({ + state: "scheduled", + when: { kind: "on-approval" }, + approval: { kind: "person", approvalId: result.approvalId }, + }); + }); + it("automations: built-ins and agent tasks with their key; pause, resume, delete; preview", async () => { const builtin = await post(`${S()}/automations`, { name: "Links", diff --git a/packages/api/test/better-auth-pins.test.ts b/packages/api/test/better-auth-pins.test.ts index f2378bab..b2fcaab8 100644 --- a/packages/api/test/better-auth-pins.test.ts +++ b/packages/api/test/better-auth-pins.test.ts @@ -51,6 +51,7 @@ describe("better-auth family pins", () => { const versions = catalog(); for (const name of [ "@better-auth/api-key", + "@better-auth/cimd", "@better-auth/kysely-adapter", "@better-auth/mcp", "@better-auth/oauth-provider", diff --git a/packages/api/test/mcp-oauth-registration.test.ts b/packages/api/test/mcp-oauth-registration.test.ts new file mode 100644 index 00000000..c1cbcac5 --- /dev/null +++ b/packages/api/test/mcp-oauth-registration.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 { describeMcpOauthRegistration } from "../src/testing-mcp-oauth-registration.ts"; + +describeMcpOauthRegistration("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(); +describeMcpOauthRegistration("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/cli/src/commands/scheduler-local.ts b/packages/cli/src/commands/scheduler-local.ts index f797449a..64b2a6d7 100644 --- a/packages/cli/src/commands/scheduler-local.ts +++ b/packages/cli/src/commands/scheduler-local.ts @@ -14,7 +14,7 @@ * (§11.6): automations stop (no occurrences, no windows, automation keys refused); releases and * gates go on. */ -import { releaseApprovalCheck } from "@cloudhal/api"; +import { oauthClientsPruneJob, releaseApprovalCheck } from "@cloudhal/api"; import { SqlCommentStore } from "@cloudhal/comments/sql"; import type { SiteGraphStore } from "@cloudhal/core-ts"; import { @@ -108,6 +108,7 @@ export function localSystemJobs( o.log(`schedules retention: ${r.runs} run(s), ${r.releases} release(s) deleted`); }, }, + oauthClientsPruneJob(sql, o.log), ]; if (o.comments) jobs.push({ diff --git a/packages/cli/src/commands/studio.ts b/packages/cli/src/commands/studio.ts index dc07a94f..e76777d7 100644 --- a/packages/cli/src/commands/studio.ts +++ b/packages/cli/src/commands/studio.ts @@ -2,8 +2,14 @@ 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 { + cimdFetch, + mcpOauthFor, + type OauthServer, + oauthRegistrationFrom, +} from "@cloudhal/api/auth"; import { CLIENT_ADDRESS_HEADER, serveNode } from "@cloudhal/api/node"; +import type { SiteGraphStore } from "@cloudhal/core-ts"; import { openLocalStore } from "@cloudhal/core-ts/local"; import { nodeTransport } from "@cloudhal/core-ts/media-node"; import { SqlScheduleStore } from "@cloudhal/schedules/sql"; @@ -170,7 +176,7 @@ 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); + const oauth = studioOauth(server.baseUrl, env, { store: local.store, log: options.log }); if (setup) { const { createAuthFor } = await import("@cloudhal/api/auth/node"); const auth = await createAuthFor({ @@ -224,6 +230,26 @@ export async function studio(options: StudioOptions): Promise { }; } +/** + * The authorization server's options (RFC-0044 §10). §3: client metadata documents come through + * the media import's pinning Node transport, never from this Studio's own host or a site's + * domain; `CLOUDHAL_MCP_CIMD`, `_DCR` and `_CLIENT_HOSTS` switch registration. + */ +function studioOauth( + baseUrl: string | undefined, + env: Readonly>, + o: { store: Pick; log: ((line: string) => void) | undefined }, +): ReturnType { + const ownHosts = async () => [ + ...(baseUrl ? [new URL(baseUrl).hostname] : []), + ...(await o.store.listSites()).flatMap((s) => s.domains.map((d) => d.hostname)), + ]; + const cimd = cimdFetch({ transport: nodeTransport(), blockedHosts: ownHosts }); + const registration = oauthRegistrationFrom(env, cimd); + if (registration.problem) o.log?.(`OAuth client registration is off: ${registration.problem}`); + return mcpOauthFor(baseUrl, env.CLOUDHAL_MCP_OAUTH, registration); +} + /** Logs why remote MCP clients cannot connect by URL (RFC-0044 §10); nothing when they can. */ function logOauthOff( log: ((line: string) => void) | undefined, diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts index 902b8c43..fdafabcf 100644 --- a/packages/cloudflare/src/admin-worker.ts +++ b/packages/cloudflare/src/admin-worker.ts @@ -47,11 +47,18 @@ 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, mcpOauthFor } from "@cloudhal/api/auth"; +import { + type CloudhalAuth, + cimdFetch, + createAuth, + mcpOauthFor, + oauthRegistrationFrom, +} 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"; import { type BlockType, createBlockRegistry, type PropsSchema } from "@cloudhal/core-ts/blocks"; +import { workersTransport } from "@cloudhal/core-ts/media"; import { SqlScheduleStore } from "@cloudhal/schedules/sql"; import { migrate, migrations, type SqlDriver } from "@cloudhal/storage"; import { D1Driver } from "@cloudhal/storage/d1"; @@ -106,20 +113,39 @@ export interface AdminEnv extends CommentsEnv, SchedulerEnv, MailEnv, BuildRooms CLOUDHAL_RENDER?: string; /** `off` turns the OAuth authorization server for remote MCP clients off (RFC-0044 §10). */ CLOUDHAL_MCP_OAUTH?: string; + /** `off`: no Client ID Metadata Documents (RFC-0044 §3). */ + CLOUDHAL_MCP_CIMD?: string; + /** `off`: no dynamic client registration (RFC-0044 §3; Cursor needs it). */ + CLOUDHAL_MCP_DCR?: string; + /** Comma-separated hosts OAuth clients may come from (`claude.ai,*.example.com`); unset: any. */ + CLOUDHAL_MCP_CLIENT_HOSTS?: 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). + * (`CLOUDHAL_MCP_OAUTH=off` turns the authorization server off). §3: client metadata documents + * are fetched with the media import's Workers transport, never from this Worker's own host or a + * site's domain (read only when a document is fetched). */ function adminAuth( env: AdminEnv, sql: D1Driver, url: URL, - run: { readonly waitUntil: (p: Promise) => void; readonly log: (line: string) => void }, + run: { + readonly waitUntil: (p: Promise) => void; + readonly log: (line: string) => void; + readonly store: Pick; + }, ): CloudhalAuth { - const oauth = mcpOauthFor(url.origin, env.CLOUDHAL_MCP_OAUTH); + const ownHosts = async () => [ + url.hostname, + ...(await run.store.listSites()).flatMap((site) => site.domains.map((d) => d.hostname)), + ]; + const cimd = cimdFetch({ transport: workersTransport(), blockedHosts: ownHosts }); + const registration = oauthRegistrationFrom(env, cimd); + if (registration.problem) run.log(`OAuth client registration is off: ${registration.problem}`); + const oauth = mcpOauthFor(url.origin, env.CLOUDHAL_MCP_OAUTH, registration); return createAuth({ database: env.DB, sql, @@ -456,7 +482,7 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { }); if (due) ctx.waitUntil(due); - const auth = adminAuth(env, sql, url, { waitUntil: (p) => ctx.waitUntil(p), log }); + const auth = adminAuth(env, sql, url, { waitUntil: (p) => ctx.waitUntil(p), log, store }); const audit = new AuditLog(sql); const approvals = new ApprovalStore(sql); const mail = adminMail(env); diff --git a/packages/cloudflare/src/scheduler.ts b/packages/cloudflare/src/scheduler.ts index e8834be5..c1b40ab0 100644 --- a/packages/cloudflare/src/scheduler.ts +++ b/packages/cloudflare/src/scheduler.ts @@ -24,7 +24,7 @@ * `CLOUDHAL_ANALYTICS_CRON=off` / `CLOUDHAL_COMMENTS_CRON=off`; both read as `alarm` until the next * `cloudhal deploy` removes them. */ -import { releaseApprovalCheck } from "@cloudhal/api"; +import { oauthClientsPruneJob, releaseApprovalCheck } from "@cloudhal/api"; import { PURGE_LIMITS } from "@cloudhal/comments"; import { SqlCommentStore } from "@cloudhal/comments/sql"; import { @@ -272,6 +272,7 @@ export function adminSystemJobs( }, }, listValuesJob(sql, { paid: env.CLOUDHAL_TICK_BUDGET === "paid", log }), + oauthClientsPruneJob(sql, log), ]; if (o.commentsOn) jobs.push({ diff --git a/packages/core-ts/src/media/fetcher.ts b/packages/core-ts/src/media/fetcher.ts index 1f554bd1..051cf75b 100644 --- a/packages/core-ts/src/media/fetcher.ts +++ b/packages/core-ts/src/media/fetcher.ts @@ -79,6 +79,8 @@ export interface SafeFetchResult { readonly address?: string; /** Bytes received (also when streamed to `onChunk`). */ readonly size: number; + /** The last hop's response headers (RFC-0044 §3: a CIMD document's cache headers). */ + readonly headers: Headers; } // ───────────────────────────── addresses ───────────────────────────── @@ -451,6 +453,7 @@ export async function safeFetch( return { bytes, size, + headers: res.headers, contentType, url: url.toString(), chain, diff --git a/packages/mcp-server/src/gate-approval.ts b/packages/mcp-server/src/gate-approval.ts index ba32179a..6f32c6bd 100644 --- a/packages/mcp-server/src/gate-approval.ts +++ b/packages/mcp-server/src/gate-approval.ts @@ -1,8 +1,8 @@ /** * 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. + * never the person behind it, and has no `auto` approval of its own (the release contract's + * `auto` approval names a key), so its gate change is filed for a person to approve. */ import { agentCredential } from "@cloudhal/agent-runtime"; import type { Release } from "@cloudhal/core-contracts"; @@ -10,10 +10,14 @@ import { canonicalInstant } from "@cloudhal/schedules"; import { type ToolContext, ToolError } from "./common.ts"; import { personApproval } from "./schedules.ts"; -/** What `set_embargo` answers an OAuth connection on live content. */ +/** What `set_embargo` answers an OAuth connection on live content without out-of-band approvals. */ 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)."; +/** What `set_embargo` answers an OAuth connection on live content: a person approves the gate. */ +export const GRANT_GATE_PENDING = + "The embargo is saved. A person must approve putting it live in Cloudhal Studio (site → Activity → Approvals); until then the content stays visible. Do not call set_embargo again for it."; + /** The agent key of a call that passed `autoReleaseVerdict` (which refuses anything else). */ export function keyOf(ctx: ToolContext): string { const credential = agentCredential(ctx.principal); diff --git a/packages/mcp-server/src/schedule-gates.ts b/packages/mcp-server/src/schedule-gates.ts new file mode 100644 index 00000000..2d55dfc8 --- /dev/null +++ b/packages/mcp-server/src/schedule-gates.ts @@ -0,0 +1,78 @@ +/** + * The gate helpers of the schedule tools (RFC-0008 §7.6, §10.8): the article or page a gate tool + * names, the own-work check, the gate write (CAS on `rev`, then a snapshot) and whether a working + * copy differs from the live version only in its gates. Split from `schedule-tools.ts` (size + * ratchet). + */ +import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts"; +import { canonicalJson } from "@cloudhal/core-ts"; +import { ACTOR_OWNERSHIP, callerPerson, type ToolContext, ToolError } from "./common.ts"; +import { liveRefs } from "./schedules.ts"; + +export type GatedNode = (ContentEntry | Page) & { kind: "contentEntry" | "page" }; + +export async function gatedNode( + ctx: ToolContext, + siteId: string, + nodeId: string, +): Promise { + const entry = await ctx.store.getContentEntry(nodeId); + if (entry && entry.siteId === siteId) return { ...entry, kind: "contentEntry" }; + const page = await ctx.store.getPage(nodeId); + if (page && page.siteId === siteId) return { ...page, kind: "page" }; + throw new ToolError("not_found", `no article or page ${nodeId} on this site`); +} + +export async function assertOwnNode(ctx: ToolContext, node: GatedNode): Promise { + if (ctx.limit?.target !== "release") return; + const owned = + node.kind === "contentEntry" && + (await (ctx.ownership ?? ACTOR_OWNERSHIP).personOf((node as ContentEntry).author)) === + callerPerson(ctx); + if (!owned) throw new ToolError("denied", "your role schedules only your own articles"); +} + +/** Writes new gates on the working copy (CAS on `expectedRev`) and snapshots, like any edit. */ +export async function writeGates( + ctx: ToolContext, + node: GatedNode, + availability: ContentEntry["availability"], + expectedRev: number, +): Promise { + if (node.rev !== expectedRev) + throw new ToolError( + "conflict", + `${node.id} is at rev ${node.rev}, you sent ${expectedRev}; read it again`, + ); + const { kind: _k, availability: _a, ...rest } = node; + const next = { ...rest, ...(availability ? { availability } : {}) }; + const saved = + node.kind === "contentEntry" + ? await ctx.store.updateContentEntry(next as ContentEntry) + : await ctx.store.updatePage(next as Page); + await ctx.store.createSnapshot(node.siteId, { + actor: ctx.principal.actor, + reason: "agentMutation", + label: `gates by ${ctx.principal.actor.name ?? ctx.principal.actor.id}`, + }); + return saved; +} + +/** True when the working copy differs from the live version only in its gates (a gate-only change). */ +export async function gateOnly( + ctx: ToolContext, + site: Site, + node: ContentEntry | Page, +): Promise { + const ref = (await liveRefs(ctx.store, site)).find((r) => r.id === node.id); + if (!ref) return false; + const live = (await ctx.store.readSnapshotNode(ref).catch(() => undefined)) as + | (ContentEntry | Page) + | undefined; + if (!live) return false; + const strip = (n: ContentEntry | Page) => { + const { availability: _a, rev: _r, updatedAt: _u, ...rest } = n; + return canonicalJson(rest); + }; + return strip(live) === strip(node); +} diff --git a/packages/mcp-server/src/schedule-tools.ts b/packages/mcp-server/src/schedule-tools.ts index 792256b5..f7b9579a 100644 --- a/packages/mcp-server/src/schedule-tools.ts +++ b/packages/mcp-server/src/schedule-tools.ts @@ -33,7 +33,7 @@ import type { ScheduleRun, Site, } from "@cloudhal/core-contracts"; -import { canonicalJson, gateChanges } from "@cloudhal/core-ts"; +import { gateChanges } from "@cloudhal/core-ts"; import { budgetLeft, canonicalInstant, @@ -62,7 +62,8 @@ import { type ToolDefinition, ToolError, } from "./common.ts"; -import { directApproval, keyOf, NO_GRANT_GATES } from "./gate-approval.ts"; +import { directApproval, GRANT_GATE_PENDING, keyOf, NO_GRANT_GATES } from "./gate-approval.ts"; +import { assertOwnNode, gatedNode, gateOnly, writeGates } from "./schedule-gates.ts"; import { approvalProblem, autoReleaseVerdict, @@ -436,66 +437,6 @@ async function releaseFor( // ───────────────────────────── gates (§7.6, §10.8) ───────────────────────────── -type GatedNode = (ContentEntry | Page) & { kind: "contentEntry" | "page" }; - -async function gatedNode(ctx: ToolContext, siteId: string, nodeId: string): Promise { - const entry = await ctx.store.getContentEntry(nodeId); - if (entry && entry.siteId === siteId) return { ...entry, kind: "contentEntry" }; - const page = await ctx.store.getPage(nodeId); - if (page && page.siteId === siteId) return { ...page, kind: "page" }; - throw new ToolError("not_found", `no article or page ${nodeId} on this site`); -} - -async function assertOwnNode(ctx: ToolContext, node: GatedNode): Promise { - if (ctx.limit?.target !== "release") return; - const owned = - node.kind === "contentEntry" && - (await (ctx.ownership ?? ACTOR_OWNERSHIP).personOf((node as ContentEntry).author)) === - callerPerson(ctx); - if (!owned) throw new ToolError("denied", "your role schedules only your own articles"); -} - -/** Writes new gates on the working copy (CAS on `expectedRev`) and snapshots, like any edit. */ -async function writeGates( - ctx: ToolContext, - node: GatedNode, - availability: ContentEntry["availability"], - expectedRev: number, -): Promise { - if (node.rev !== expectedRev) - throw new ToolError( - "conflict", - `${node.id} is at rev ${node.rev}, you sent ${expectedRev}; read it again`, - ); - const { kind: _k, availability: _a, ...rest } = node; - const next = { ...rest, ...(availability ? { availability } : {}) }; - const saved = - node.kind === "contentEntry" - ? await ctx.store.updateContentEntry(next as ContentEntry) - : await ctx.store.updatePage(next as Page); - await ctx.store.createSnapshot(node.siteId, { - actor: ctx.principal.actor, - reason: "agentMutation", - label: `gates by ${ctx.principal.actor.name ?? ctx.principal.actor.id}`, - }); - return saved; -} - -/** True when the working copy differs from the live version only in its gates (a gate-only change). */ -async function gateOnly(ctx: ToolContext, site: Site, node: ContentEntry | Page): Promise { - const ref = (await liveRefs(ctx.store, site)).find((r) => r.id === node.id); - if (!ref) return false; - const live = (await ctx.store.readSnapshotNode(ref).catch(() => undefined)) as - | (ContentEntry | Page) - | undefined; - if (!live) return false; - const strip = (n: ContentEntry | Page) => { - const { availability: _a, rev: _r, updatedAt: _u, ...rest } = n; - return canonicalJson(rest); - }; - return strip(live) === strip(node); -} - /** One node, one `on-approval` release carrying its gate change, scheduled with `approval`. */ async function gateRelease( ctx: ToolContext, @@ -522,6 +463,47 @@ async function gateRelease( return scheduleWith(ctx, rec, { kind: "on-approval" }, approval); } +/** What `set_embargo` answers (`approval-pending`: an OAuth connection's gate, RFC-0044). */ +const EMBARGO_STATUS = ["set", "scheduled", "approval-pending", "with-next-publish"] as const; +const EMBARGO_RESULT = obj( + { + node: ANY, + release: ANY, + status: { type: "string", enum: [...EMBARGO_STATUS] }, + approvalId: { type: "string" }, + message: { type: "string" }, + }, + ["node", "status"], +); + +/** + * An OAuth connection's gate-only change (RFC-0044): it has no approval of its own, so the pinned + * release `gateRelease` would schedule is filed as a `schedule_release` request to run on + * approval, bound to its `rl1:` hash like every release request (Studio's Approvals, and a + * `schedule-approval-due` notification); approving it puts the embargo live. Without out-of-band + * approvals the gate waits for the next publish. + */ +async function gateRequest(ctx: ToolContext, site: Site, nodeId: string) { + if (!ctx.approvals) return { status: "with-next-publish" as const, message: NO_GRANT_GATES }; + const pinned = await releaseService(deps(ctx), ctx.store, ctx.now).create({ + siteId: site.id, + set: [nodeId], + createdBy: ctx.principal.actor, + metadata: { [RELEASE_META.source]: "set_embargo", [RELEASE_META.reduce]: "1" }, + }); + const rec = await recordOf(ctx, site.id, pinned.release.id); + const when = { kind: "on-approval" } as const; + const filed = await fileRequest( + ctx, + rec, + when, + "schedule_release", + "Put the embargo live", + "tool", + ); + return { ...filed, message: GRANT_GATE_PENDING }; +} + // ───────────────────────────── runs (§11.2) ───────────────────────────── /** The run envelope `begin_run` answers with (§11.2). */ @@ -1012,15 +994,7 @@ export function scheduleTools(): ToolDefinition[] { { siteId: id, nodeId: id, from: TIMESTAMP, expectedRev: { type: "integer", minimum: 0 } }, ["siteId", "nodeId", "from", "expectedRev"], ), - outputSchema: obj( - { - node: ANY, - release: ANY, - status: { type: "string", enum: ["set", "scheduled", "with-next-publish"] }, - message: { type: "string" }, - }, - ["node", "status"], - ), + outputSchema: EMBARGO_RESULT, annotations: WRITE, scopes: ["schedules:write", "content:write"], own: "release", @@ -1060,7 +1034,7 @@ export function scheduleTools(): ToolDefinition[] { "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 approval = directApproval(ctx, at); - if (!approval) return { node: saved, status: "with-next-publish", message: NO_GRANT_GATES }; + if (!approval) return { node: saved, ...(await gateRequest(ctx, site, node.id)) }; 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 e75401a4..7e86a4ae 100644 --- a/packages/mcp-server/test/schedules.test.ts +++ b/packages/mcp-server/test/schedules.test.ts @@ -489,7 +489,7 @@ 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 () => { + it("set_embargo through an OAuth connection files the gate for a person, never approves it (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"); @@ -505,8 +505,28 @@ describe("gates over MCP (RFC-0008 §7.6, §10.8)", () => { expectedRev: live.rev, }); expect(hide.isError, hide.text).toBe(false); - expect(hide.data?.status).toBe("with-next-publish"); - expect(hide.data?.release).toBeUndefined(); + // RFC-0044 Phase 3: the gate-only release waits for a person, who approves it in Studio. + expect(hide.data).toMatchObject({ + status: "approval-pending", + node: { availability: { from: plus(7 * HOUR) } }, + }); + const release = hide.data?.release as Release; + expect(release).toMatchObject({ + state: "awaiting_approval", + when: { kind: "on-approval" }, + metadata: { "cloudhal.source": "set_embargo", "cloudhal.reduce": "1" }, + }); + expect(release.approval ?? null).toBeNull(); + const request = await s.approvals.get(hide.data?.approvalId as string); + expect(request).toMatchObject({ + tool: "schedule_release", + status: "pending", + cause: "tool", + releaseId: release.id, + actor: { kind: "agent" }, + input: { siteId: s.site.id, releaseId: release.id, when: { kind: "on-approval" } }, + }); + expect(request?.stateHash).toMatch(/^rl1:/); const releases = await s.schedules.listReleases(s.site.id); expect( releases.some((r) => r.approval?.kind === "person" && r.approval.by.id === "user_sched00001"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1357a242..72344176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ catalogs: '@better-auth/api-key': specifier: 1.7.18 version: 1.7.18 + '@better-auth/cimd': + specifier: 1.7.18 + version: 1.7.18 '@better-auth/core': specifier: 1.7.18 version: 1.7.18 @@ -592,6 +595,9 @@ importers: '@better-auth/api-key': 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-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/cimd': + 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/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)) '@better-auth/core': specifier: 'catalog:' 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) @@ -1423,6 +1429,14 @@ packages: better-auth: ^1.7.18 better-call: 1.4.5 + '@better-auth/cimd@1.7.18': + resolution: {integrity: sha512-Oln0ZIQwkzqCGjMJpP0RSBjYvrS4APozUpPfUVIuIdhTAepAprp4VWu1WlLCs0gFHhsJlq3QjlFfv80W/lJ/IA==} + peerDependencies: + '@better-auth/core': ^1.7.18 + '@better-auth/oauth-provider': ^1.7.18 + better-auth: ^1.7.18 + better-call: 1.4.5 + '@better-auth/core@1.7.18': resolution: {integrity: sha512-GPue1yR24fd+RkCiOpVtdBwtCqkgGHRMGxUL6A11LzUv0JsHA2hnIFEdoxorr9TWO2kJLvYOuUu0vjV9Wxd4AA==} peerDependencies: @@ -5275,6 +5289,13 @@ snapshots: better-call: 1.4.5(zod@4.6.7) zod: 4.6.7 + '@better-auth/cimd@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/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))': + 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/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) + '@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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c1636e9..93034eeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,6 +56,7 @@ catalog: "@better-auth/core": 1.7.18 "@better-auth/oauth-provider": 1.7.18 "@better-auth/mcp": 1.7.18 + "@better-auth/cimd": 1.7.18 jose: 6.2.12 "@better-auth/utils": 0.5.1 better-call: 1.4.5 diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 44a8d92f..0957c308 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": 1579, + "packages/mcp-server/src/schedule-tools.ts": 1553, "packages/mcp-server/src/schedules.ts": 833, "packages/mcp-server/src/taxonomy-tools.ts": 1433, "packages/schedules/src/recurrence.ts": 939, @@ -304,7 +304,7 @@ "params": 5 }, "packages/api/src/app.ts::createApi": { - "lines": 160 + "lines": 157 }, "packages/api/src/build-turn.ts::runBuildTurn": { "lines": 260 @@ -505,7 +505,7 @@ "params": 5 }, "packages/mcp-server/src/schedule-tools.ts::scheduleTools": { - "lines": 1012 + "lines": 1004 }, "packages/mcp-server/src/schedules.ts::autoReleaseVerdict": { "params": 6 From 032397da5cb5ef444d24c2e8740cdb54f53fbe2a Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:36:41 +0300 Subject: [PATCH 2/7] fix(studio): show an OAuth client's identity safely on the consent screen (#144) Review of #178, finding 6. A client name is self-asserted (a metadata document's or a registration's client_name), so: - consent info and the stored grant show it plain: control, bidi, zero-width, tag and other format characters removed, space runs collapsed, at most 100 code points (client-identity.ts, linear); - ConsentInfo.client gains kind (document, self-registered, server) and lookalike: a self-registered name containing Claude, Claude Code, ChatGPT, Codex, VS Code or Cursor; - Studio leads with a document client's host, in the title too ("Connect {client} from {host}"), labels DCR clients "Self-registered, not verified", and warns about look-alike names. Three new oauthConsent strings in en; the 18 other languages are machine drafts from pnpm i18n:import, awaiting review. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- apps/studio/src/i18n/generated/params.ts | 2 + apps/studio/src/i18n/lazy/oauthConsent.ar.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.de.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.en.ts | 4 + apps/studio/src/i18n/lazy/oauthConsent.es.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.fa.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.fr.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.he.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.hi.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.id.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.it.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.ja.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.ko.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.nl.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.pl.ts | 6 +- .../src/i18n/lazy/oauthConsent.pt-BR.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.ru.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.tr.ts | 6 +- apps/studio/src/i18n/lazy/oauthConsent.uk.ts | 6 +- .../src/i18n/lazy/oauthConsent.zh-Hans.ts | 6 +- apps/studio/src/lib/api/agent-grants.ts | 11 +- apps/studio/src/routes/OAuthConsentPage.tsx | 30 ++++- apps/studio/test/oauth-consent.test.tsx | 26 +++- packages/api/src/auth/agent-consent.ts | 30 ++++- packages/api/src/auth/client-identity.ts | 122 ++++++++++++++++++ packages/api/src/auth/oauth-cimd-policy.ts | 45 ------- packages/api/src/testing-mcp-oauth-server.ts | 6 +- 27 files changed, 304 insertions(+), 80 deletions(-) create mode 100644 packages/api/src/auth/client-identity.ts delete mode 100644 packages/api/src/auth/oauth-cimd-policy.ts diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 8857c12c..5557b9fe 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -550,7 +550,9 @@ export interface MessageParams { "oauthConsent.scopes.notInRole": { label: string | number }; "oauthConsent.scopes.sites": { count: number }; "oauthConsent.title": { client: string | number }; + "oauthConsent.titleFrom": { client: string | number; host: string | number }; "oauthConsent.who.fromHost": { host: string | number }; + "oauthConsent.who.lookalike": { client: string | number }; "oauthConsent.who.sendsBack": { host: string | number }; "overview.commentsWaiting": { count: number }; "pageStatus.leftOut": { count: number }; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts index 936fa00b..a3bdb8e7 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts @@ -1,11 +1,12 @@ // 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: d6d17d53696a6da9 +// hash: a854ea3aec15d2d2 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "ربط {client}", + titleFrom: "ربط {client} من {host}", description: "يطلب وكيل العمل في هذا الـStudio نيابةً عنك. تحقّق مما يُسمح له به قبل الموافقة.", invalid: { title: "لا يمكن استخدام هذا الطلب", @@ -16,6 +17,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "من يطلب", fromHost: "يُعرَّف عبر {host}", registered: "مسجّل في هذا الـStudio", + selfRegistered: "مسجّل ذاتيًا، غير مُتحقَّق منه", + lookalike: + "يشبه الاسم {client}، لكن أي أحد يمكنه تسجيل عميل بأي اسم. وافق فقط إذا بدأت هذا الاتصال من {client} بنفسك وكان العنوان أدناه هو الذي يستخدمه.", sendsBack: "يعيدك إلى {host}", thisComputer: "يعيدك إلى تطبيق على هذا الحاسوب", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.de.ts b/apps/studio/src/i18n/lazy/oauthConsent.de.ts index f92f280d..4d0149f9 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.de.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.de.ts @@ -1,11 +1,12 @@ // 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: 07b0fd828ea3973f +// hash: 2637d576f3883563 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} verbinden", + titleFrom: "{client} von {host} verbinden", description: "Ein Agent möchte in diesem Studio für dich arbeiten. Prüfe, was er darf, bevor du zustimmst.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Wer fragt", fromHost: "Identifiziert durch {host}", registered: "In diesem Studio registriert", + selfRegistered: "Selbst registriert, nicht geprüft", + lookalike: + "Der Name sieht nach {client} aus, aber jeder kann einen Client unter beliebigem Namen registrieren. Stimme nur zu, wenn du diese Verbindung selbst aus {client} gestartet hast und die Adresse unten die ist, die es verwendet.", sendsBack: "Leitet dich zurück zu {host}", thisComputer: "Leitet dich zurück zu einer App auf diesem Computer", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.en.ts b/apps/studio/src/i18n/lazy/oauthConsent.en.ts index 824f54f2..0b05d666 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.en.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.en.ts @@ -6,6 +6,7 @@ export const messages = { oauthConsent: { title: "Connect {client}", + titleFrom: "Connect {client} from {host}", description: "An agent asks to work in this Studio for you. Check what it may do before you approve.", invalid: { @@ -17,6 +18,9 @@ export const messages = { title: "Who is asking", fromHost: "Identified by {host}", registered: "Registered with this Studio", + selfRegistered: "Self-registered, not verified", + lookalike: + "The name looks like {client}, but anyone can register a client under any name. Approve only if you started this connection from {client} yourself and the address below is the one it uses.", sendsBack: "Sends you back to {host}", thisComputer: "Sends you back to an app on this computer", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.es.ts b/apps/studio/src/i18n/lazy/oauthConsent.es.ts index e66f7343..651a25c5 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.es.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.es.ts @@ -1,11 +1,12 @@ // 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: 6e539e2b036bb8c6 +// hash: 732e36cfbbdc92b4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Conectar {client}", + titleFrom: "Conectar {client} desde {host}", description: "Un agente pide trabajar en este Studio por ti. Revisa lo que puede hacer antes de aprobar.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Quién lo pide", fromHost: "Identificado por {host}", registered: "Registrado en este Studio", + selfRegistered: "Autorregistrado, sin verificar", + lookalike: + "El nombre parece {client}, pero cualquiera puede registrar un cliente con cualquier nombre. Aprueba solo si iniciaste tú esta conexión desde {client} y la dirección de abajo es la que usa.", sendsBack: "Te devuelve a {host}", thisComputer: "Te devuelve a una app de este equipo", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts index db08d4f8..b40cca05 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts @@ -1,11 +1,12 @@ // 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: 9378e7d2a7816050 +// hash: 7a8af8024fb008cf import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "اتصال {client}", + titleFrom: "اتصال {client} از {host}", description: "یک عامل می‌خواهد در این Studio از طرف شما کار کند. پیش از تأیید، ببینید چه کارهایی مجاز است.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "چه کسی درخواست می‌دهد", fromHost: "شناسایی‌شده با {host}", registered: "ثبت‌شده در این Studio", + selfRegistered: "خودثبت‌شده، تأییدنشده", + lookalike: + "این نام شبیه {client} است، اما هر کسی می‌تواند کلاینتی با هر نامی ثبت کند. فقط وقتی تأیید کن که خودت این اتصال را از {client} شروع کرده‌ای و نشانی زیر همان نشانی‌ای است که استفاده می‌کند.", sendsBack: "شما را به {host} برمی‌گرداند", thisComputer: "شما را به برنامه‌ای در این رایانه برمی‌گرداند", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts index fa02c1fb..b948e372 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts @@ -1,11 +1,12 @@ // 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: 63aa7946c1e148ca +// hash: c059ed6a7785f66f import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Connecter {client}", + titleFrom: "Connecter {client} depuis {host}", description: "Un agent demande à travailler dans ce Studio pour vous. Vérifiez ce qu'il peut faire avant d'approuver.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Qui le demande", fromHost: "Identifié par {host}", registered: "Enregistré dans ce Studio", + selfRegistered: "Auto-inscrit, non vérifié", + lookalike: + "Le nom ressemble à {client}, mais n’importe qui peut inscrire un client sous n’importe quel nom. N’approuvez que si vous avez lancé vous-même cette connexion depuis {client} et que l’adresse ci-dessous est celle qu’il utilise.", sendsBack: "Vous renvoie vers {host}", thisComputer: "Vous renvoie vers une application de cet ordinateur", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.he.ts b/apps/studio/src/i18n/lazy/oauthConsent.he.ts index 004d3d06..416af7e9 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.he.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.he.ts @@ -1,11 +1,12 @@ // 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: 37c0b05241b08508 +// hash: 69b6f06bb5da7e88 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "חיבור {client}", + titleFrom: "חיבור {client} מ-{host}", description: "סוכן מבקש לעבוד ב-Studio הזה בשמך. כדאי לבדוק מה מותר לו לפני האישור.", invalid: { title: "אי אפשר להשתמש בבקשה הזו", @@ -16,6 +17,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "מי מבקש", fromHost: "מזוהה על ידי {host}", registered: "רשום ב-Studio הזה", + selfRegistered: "נרשם בעצמו, לא מאומת", + lookalike: + "השם נראה כמו {client}, אבל כל אחד יכול לרשום לקוח בכל שם. אשר רק אם אתה התחלת את החיבור הזה מ-{client} והכתובת שלמטה היא זו שהוא משתמש בה.", sendsBack: "יחזיר אותך אל {host}", thisComputer: "יחזיר אותך לאפליקציה במחשב הזה", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts index 5bdf098e..638b361d 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts @@ -1,11 +1,12 @@ // 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: b558b0bef4ac3ed0 +// hash: 84204e6400b36dea import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} कनेक्ट करें", + titleFrom: "{host} से {client} कनेक्ट करें", description: "एक एजेंट इस Studio में आपकी ओर से काम करना चाहता है। मंज़ूरी देने से पहले देखें कि वह क्या कर सकता है।", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "कौन पूछ रहा है", fromHost: "{host} से पहचाना गया", registered: "इस Studio में रजिस्टर्ड", + selfRegistered: "स्वयं पंजीकृत, सत्यापित नहीं", + lookalike: + "यह नाम {client} जैसा दिखता है, लेकिन कोई भी किसी भी नाम से क्लाइंट पंजीकृत कर सकता है। केवल तभी स्वीकृत करें जब आपने यह कनेक्शन खुद {client} से शुरू किया हो और नीचे दिया गया पता वही हो जिसका वह उपयोग करता है।", sendsBack: "आपको {host} पर वापस भेजता है", thisComputer: "आपको इस कंप्यूटर के किसी ऐप पर वापस भेजता है", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.id.ts b/apps/studio/src/i18n/lazy/oauthConsent.id.ts index a8166525..a90222c7 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.id.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.id.ts @@ -1,11 +1,12 @@ // 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: 8b80d49448b0fcea +// hash: 24728cb99a81ccfd import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Hubungkan {client}", + titleFrom: "Hubungkan {client} dari {host}", description: "Sebuah agen meminta untuk bekerja di Studio ini atas nama Anda. Periksa apa yang boleh dilakukannya sebelum menyetujui.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Siapa yang meminta", fromHost: "Diidentifikasi oleh {host}", registered: "Terdaftar di Studio ini", + selfRegistered: "Terdaftar sendiri, belum diverifikasi", + lookalike: + "Namanya mirip {client}, tetapi siapa pun bisa mendaftarkan klien dengan nama apa pun. Setujui hanya jika kamu sendiri memulai koneksi ini dari {client} dan alamat di bawah adalah yang dipakainya.", sendsBack: "Mengembalikan Anda ke {host}", thisComputer: "Mengembalikan Anda ke aplikasi di komputer ini", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.it.ts b/apps/studio/src/i18n/lazy/oauthConsent.it.ts index b2cc5ed4..c7082fc0 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.it.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.it.ts @@ -1,11 +1,12 @@ // 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: 0b67d5b2bc3557b1 +// hash: 25a76d3cc2ce99ad import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Collega {client}", + titleFrom: "Collega {client} da {host}", description: "Un agente chiede di lavorare in questo Studio per te. Controlla cosa può fare prima di approvare.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Chi lo chiede", fromHost: "Identificato da {host}", registered: "Registrato in questo Studio", + selfRegistered: "Registrato da sé, non verificato", + lookalike: + "Il nome sembra {client}, ma chiunque può registrare un client con qualsiasi nome. Approva solo se hai avviato tu questa connessione da {client} e l’indirizzo qui sotto è quello che usa.", sendsBack: "Ti rimanda a {host}", thisComputer: "Ti rimanda a un'app su questo computer", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts index cbdbe6f4..ab78aae1 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts @@ -1,11 +1,12 @@ // 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: d97237187f90ddf0 +// hash: f83bb9fdc3583319 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} を接続", + titleFrom: "{host} の {client} を接続", description: "エージェントがあなたの代わりにこの Studio で作業することを求めています。承認する前に、できることを確認してください。", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "依頼元", fromHost: "{host} で識別", registered: "この Studio に登録済み", + selfRegistered: "自己登録、未確認", + lookalike: + "名前は {client} に見えますが、誰でも任意の名前でクライアントを登録できます。この接続を自分で {client} から開始し、下のアドレスがそのクライアントの使うものである場合にのみ承認してください。", sendsBack: "{host} に戻ります", thisComputer: "このコンピューター上のアプリに戻ります", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts index 1273a411..6ef789bc 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts @@ -1,11 +1,12 @@ // 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: c65704fd372347a7 +// hash: b1ab9fd30aa09b44 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} 연결", + titleFrom: "{host}의 {client} 연결", description: "에이전트가 이 Studio에서 나를 대신해 일하겠다고 요청합니다. 승인하기 전에 할 수 있는 일을 확인하세요.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "요청한 곳", fromHost: "{host}(으)로 식별됨", registered: "이 Studio에 등록됨", + selfRegistered: "자체 등록됨, 확인되지 않음", + lookalike: + "이름이 {client}처럼 보이지만 누구나 어떤 이름으로든 클라이언트를 등록할 수 있습니다. 직접 {client}에서 이 연결을 시작했고 아래 주소가 그 클라이언트가 쓰는 주소일 때만 승인하세요.", sendsBack: "{host}(으)로 돌아갑니다", thisComputer: "이 컴퓨터의 앱으로 돌아갑니다", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts index 334b3fc1..7007dbfa 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts @@ -1,11 +1,12 @@ // 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: 0cc1edc4ecc6e454 +// hash: 23243900d9cc5640 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} verbinden", + titleFrom: "{client} van {host} koppelen", description: "Een agent vraagt om voor je in deze Studio te werken. Controleer wat hij mag voordat je goedkeurt.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Wie het vraagt", fromHost: "Geïdentificeerd door {host}", registered: "Geregistreerd bij deze Studio", + selfRegistered: "Zelf geregistreerd, niet geverifieerd", + lookalike: + "De naam lijkt op {client}, maar iedereen kan een client onder elke naam registreren. Keur alleen goed als je deze verbinding zelf vanuit {client} hebt gestart en het adres hieronder het adres is dat het gebruikt.", sendsBack: "Stuurt je terug naar {host}", thisComputer: "Stuurt je terug naar een app op deze computer", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts index acc22e3a..576236f5 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts @@ -1,11 +1,12 @@ // 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: b4c66e63431ba80f +// hash: 7347607b5df34bb4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Połącz {client}", + titleFrom: "Połącz {client} z {host}", description: "Agent prosi o pracę w tym Studio w twoim imieniu. Sprawdź, co może robić, zanim zatwierdzisz.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Kto prosi", fromHost: "Identyfikowany przez {host}", registered: "Zarejestrowany w tym Studio", + selfRegistered: "Zarejestrowany samodzielnie, niezweryfikowany", + lookalike: + "Nazwa wygląda jak {client}, ale każdy może zarejestrować klienta pod dowolną nazwą. Zatwierdź tylko, jeśli to ty rozpocząłeś to połączenie z {client}, a adres poniżej jest tym, którego używa.", sendsBack: "Odeśle cię do {host}", thisComputer: "Odeśle cię do aplikacji na tym komputerze", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts index 7d85e344..8f06447c 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts @@ -1,11 +1,12 @@ // 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: 2356f90339549047 +// hash: 2d03f9147ddc9589 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Conectar {client}", + titleFrom: "Conectar {client} de {host}", description: "Um agente pede para trabalhar neste Studio por você. Confira o que ele pode fazer antes de aprovar.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Quem está pedindo", fromHost: "Identificado por {host}", registered: "Registrado neste Studio", + selfRegistered: "Autorregistrado, não verificado", + lookalike: + "O nome parece {client}, mas qualquer pessoa pode registrar um cliente com qualquer nome. Aprove só se você mesmo iniciou esta conexão pelo {client} e o endereço abaixo é o que ele usa.", sendsBack: "Leva você de volta para {host}", thisComputer: "Leva você de volta para um app neste computador", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts index c9130bba..af910d1b 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts @@ -1,11 +1,12 @@ // 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: 48e760099b8a5100 +// hash: d5adc4645fcf9f74 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Подключить {client}", + titleFrom: "Подключить {client} с {host}", description: "Агент просит работать в этом Studio от вашего имени. Проверьте, что ему можно, прежде чем разрешить.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Кто просит", fromHost: "Идентифицирован через {host}", registered: "Зарегистрирован в этом Studio", + selfRegistered: "Зарегистрирован сам, не проверен", + lookalike: + "Название похоже на {client}, но кто угодно может зарегистрировать клиента под любым именем. Одобряйте, только если вы сами начали это подключение из {client} и адрес ниже — тот, который он использует.", sendsBack: "Вернёт вас на {host}", thisComputer: "Вернёт вас в приложение на этом компьютере", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts index 9e21eb2a..f4b21fc7 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts @@ -1,11 +1,12 @@ // 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: ed9f39fdef85a60c +// hash: 3b2ad4ef50cd21c0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} bağla", + titleFrom: "{host} üzerinden {client} bağla", description: "Bir agent bu Studio'da senin adına çalışmak istiyor. Onaylamadan önce neler yapabileceğine bak.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Kim istiyor", fromHost: "{host} ile tanımlanıyor", registered: "Bu Studio'ya kayıtlı", + selfRegistered: "Kendini kaydetmiş, doğrulanmamış", + lookalike: + "Ad {client} gibi görünüyor ama herkes herhangi bir adla istemci kaydedebilir. Yalnızca bu bağlantıyı {client} üzerinden kendin başlattıysan ve aşağıdaki adres onun kullandığı adresse onayla.", sendsBack: "Seni {host} adresine geri gönderir", thisComputer: "Seni bu bilgisayardaki bir uygulamaya geri gönderir", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts index f6151423..faf4d322 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts @@ -1,11 +1,12 @@ // 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: 7666c9230f67d63a +// hash: a0a05410b77f41f5 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Під'єднати {client}", + titleFrom: "Підключити {client} з {host}", description: "Агент просить працювати в цьому Studio від вашого імені. Перевірте, що йому можна, перш ніж дозволити.", invalid: { @@ -17,6 +18,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "Хто просить", fromHost: "Ідентифікований через {host}", registered: "Зареєстрований у цьому Studio", + selfRegistered: "Зареєстрований сам, не перевірений", + lookalike: + "Назва схожа на {client}, але будь-хто може зареєструвати клієнта під будь-якою назвою. Схвалюйте, лише якщо ви самі почали це підключення з {client} і адреса нижче — та, яку він використовує.", sendsBack: "Поверне вас на {host}", thisComputer: "Поверне вас у застосунок на цьому комп'ютері", loopbackWarning: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts index 98cad255..73c6d62a 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts @@ -1,11 +1,12 @@ // 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: 1ac1987970d2db80 +// hash: a00f4f54efbcdb56 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "连接 {client}", + titleFrom: "连接来自 {host} 的 {client}", description: "有个代理请求代表你在这个 Studio 中工作。批准前请先查看它可以做什么。", invalid: { title: "无法使用此请求", @@ -15,6 +16,9 @@ export const messages: NamespaceMessages<"oauthConsent"> = { title: "谁在请求", fromHost: "由 {host} 标识", registered: "已在此 Studio 注册", + selfRegistered: "自行注册,未经验证", + lookalike: + "这个名称看起来像 {client},但任何人都可以用任意名称注册客户端。只有当你亲自从 {client} 发起此连接,并且下方地址正是它使用的地址时才批准。", sendsBack: "会把你送回 {host}", thisComputer: "会把你送回这台电脑上的应用", loopbackWarning: diff --git a/apps/studio/src/lib/api/agent-grants.ts b/apps/studio/src/lib/api/agent-grants.ts index 76ad4a05..09ed332a 100644 --- a/apps/studio/src/lib/api/agent-grants.ts +++ b/apps/studio/src/lib/api/agent-grants.ts @@ -14,7 +14,16 @@ export interface ConsentScope { /** What the consent screen shows (`GET /api/agent-grants/consent`). */ export interface ConsentInfo { - client: { id: string; name: string; host?: string }; + client: { + id: string; + /** Self-asserted, made safe to show by the server (plain text, at most 100 characters). */ + name: string; + /** `document`: a metadata-document client, identified by `host`; `self-registered`: DCR. */ + kind: "document" | "self-registered" | "server"; + host?: string; + /** A self-registered client's name that contains a well-known client's. */ + lookalike?: string; + }; redirect: { host: string; loopback: boolean }; loopbackOnly: boolean; scopes: ConsentScope[]; diff --git a/apps/studio/src/routes/OAuthConsentPage.tsx b/apps/studio/src/routes/OAuthConsentPage.tsx index 1eb5ad2e..aecdc969 100644 --- a/apps/studio/src/routes/OAuthConsentPage.tsx +++ b/apps/studio/src/routes/OAuthConsentPage.tsx @@ -43,11 +43,16 @@ export function OAuthConsentPage() { retry: false, staleTime: Number.POSITIVE_INFINITY, }); - const name = info.data?.client.name ?? ""; + const client = info.data?.client; + const name = client?.name ?? ""; + // A metadata-document client's host is its identity: it stays in view whatever the name says. + const title = client?.host + ? c("oauthConsent.titleFrom", { client: name, host: client.host }) + : c("oauthConsent.title", { client: name }); return ( } - title={c("oauthConsent.title", { client: name })} + title={title} description={c("oauthConsent.description")} loading={info.isPending && request.length > 0} width="narrow" @@ -119,12 +124,17 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo })
+ {info.client.host && ( + {c("oauthConsent.who.fromHost", { host: info.client.host })} + )} {info.client.name} - - {info.client.host - ? c("oauthConsent.who.fromHost", { host: info.client.host }) - : c("oauthConsent.who.registered")} - + {!info.client.host && ( + + {info.client.kind === "self-registered" + ? c("oauthConsent.who.selfRegistered") + : c("oauthConsent.who.registered")} + + )} {info.redirect.loopback ? c("oauthConsent.who.thisComputer") @@ -132,6 +142,12 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo })
+ {info.client.lookalike && ( + + )} {info.loopbackOnly && ( { }), ); renderPage(); - expect(await screen.findByText("Connect Claude Code")).toBeTruthy(); + // The document's host is in the title and leads the identity, whatever the name says. + expect(await screen.findByText("Connect Claude Code from claude.ai")).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. @@ -78,6 +79,27 @@ describe("OAuth consent screen", () => { }); }); + it("labels a self-registered client as not verified and warns about a well-known name", async () => { + serve( + ...asRole("owner"), + on("GET", "/api/agent-grants/consent", { + status: 200, + body: { + ...INFO, + client: { id: "dcr1", name: "Claude", kind: "self-registered", lookalike: "Claude" }, + redirect: { host: "claude-ai.callback.example", loopback: false }, + loopbackOnly: false, + }, + }), + ); + renderPage(); + expect(await screen.findByText("Connect Claude")).toBeTruthy(); + expect(screen.getByText("Self-registered, not verified")).toBeTruthy(); + expect(screen.queryByText("Registered with this Studio")).toBeNull(); + expect(screen.getByText(/The name looks like Claude, but anyone can register/)).toBeTruthy(); + expect(screen.getByText("Sends you back to claude-ai.callback.example")).toBeTruthy(); + }); + it("asks for explicit consent before an Auto connection", async () => { const fetch = serve( ...asRole("owner"), diff --git a/packages/api/src/auth/agent-consent.ts b/packages/api/src/auth/agent-consent.ts index e7374bbc..9cb0c916 100644 --- a/packages/api/src/auth/agent-consent.ts +++ b/packages/api/src/auth/agent-consent.ts @@ -21,6 +21,13 @@ import { roleScopes, type Scope, } from "@cloudhal/agent-runtime"; +import { + type ClientKind, + clientKind, + displayName, + documentHost, + lookalikeOf, +} from "./client-identity.ts"; import type { AuthDeps } from "./deps.ts"; import { type AgentGrant, agentIdForGrant } from "./grants.ts"; import { SERVER_KEY_HEADER } from "./instance.ts"; @@ -36,9 +43,14 @@ export interface ConsentScope { export interface ConsentInfo { readonly client: { readonly id: string; + /** The self-asserted name, made safe to show (`displayName`: plain, at most 100). */ readonly name: string; + /** How the server knows the client (`client-identity.ts`). */ + readonly kind: ClientKind; /** The `client_id` host of a metadata-document client (`claude.ai`), else absent. */ readonly host?: string | undefined; + /** A self-registered client's name that contains a well-known client's (`Claude`). */ + readonly lookalike?: 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 }; @@ -58,6 +70,9 @@ export interface ConsentInfo { interface ClientRow { clientId: string; name?: string | null; + clientDiscoveryId?: string | null; + userId?: string | null; + referenceId?: string | null; redirectUris?: string[] | string | null; disabled?: boolean | null; } @@ -124,15 +139,18 @@ export async function consentInfo( 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 host = documentHost(client); + const kind = clientKind(client); + const name = displayName(client.name, client.clientId); + const lookalike = kind === "self-registered" ? lookalikeOf(name) : 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 } : {}), + name, + kind, + ...(host ? { host } : {}), + ...(lookalike ? { lookalike } : {}), }, redirect, loopbackOnly: registered.length > 0 && registered.every((u) => hostOf(u)?.loopback), @@ -235,7 +253,7 @@ export async function giveConsent( { userId: who.userId, clientId: client.clientId, - clientName: (client.name?.trim() || client.clientId).slice(0, 100), + clientName: displayName(client.name, client.clientId), scopes: effective, mode: input.mode, }, diff --git a/packages/api/src/auth/client-identity.ts b/packages/api/src/auth/client-identity.ts new file mode 100644 index 00000000..cc9e09c9 --- /dev/null +++ b/packages/api/src/auth/client-identity.ts @@ -0,0 +1,122 @@ +/** + * RFC-0044 §4 and §6 ("Client impersonation"): what the consent screen and "Connected agents" may + * say about a client. A client's name is self-asserted (a metadata document's or a registration's + * `client_name`), so it is shown only as plain, short text, and next to the identity the server + * can vouch for: + * + * - `document`: a Client ID Metadata Document client; the document URL's host is its identity. + * - `self-registered`: dynamic registration; nothing is verified, and a name that looks like a + * well-known client's is flagged (`lookalike`). + * - `server`: a client the server itself stores (none are made today). + * + * Linear only (AGENTS.md rule 13): one pass over the code points, no regular expression. + */ + +/** The longest name shown (as DCR's own limit). */ +export const CLIENT_NAME_MAX = 100; + +export type ClientKind = "document" | "self-registered" | "server"; + +interface ClientRowIdentity { + readonly clientId: string; + readonly name?: string | null; + readonly clientDiscoveryId?: string | null; + readonly userId?: string | null; + readonly referenceId?: string | null; +} + +/** Clients that identify themselves with a metadata document (or, for Cursor, register). */ +const WELL_KNOWN = [ + "Claude Code", + "Claude", + "ChatGPT", + "Codex", + "VS Code", + "Visual Studio Code", + "Cursor", +]; + +/** Code points never shown: controls, bidi controls and marks, zero-width and other format characters. */ +function hidden(cp: number): boolean { + if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return true; // C0, DEL, C1 + if (cp === 0xad || cp === 0x061c || cp === 0x180e) return true; // soft hyphen, ALM, MVS + if (cp >= 0x200b && cp <= 0x200f) return true; // zero-width, LRM, RLM + if (cp >= 0x202a && cp <= 0x202e) return true; // bidi embeddings and overrides + if (cp >= 0x2060 && cp <= 0x206f) return true; // word joiner, invisible operators, isolates + if (cp === 0xfeff || (cp >= 0xfff9 && cp <= 0xfffb)) return true; // BOM, annotations + if (cp >= 0xe0000 && cp <= 0xe007f) return true; // tag characters + return false; +} + +/** Space characters, collapsed to one ASCII space (U+2003 and friends included). */ +function space(cp: number): boolean { + return ( + cp === 0x20 || + cp === 0xa0 || + cp === 0x1680 || + (cp >= 0x2000 && cp <= 0x200a) || + cp === 0x2028 || + cp === 0x2029 || + cp === 0x202f || + cp === 0x205f || + cp === 0x3000 + ); +} + +/** + * A client name as it may be shown: hidden characters removed, runs of spaces collapsed, trimmed, + * at most `CLIENT_NAME_MAX` code points; `fallback` when nothing is left. + */ +export function displayName(raw: string | null | undefined, fallback: string): string { + let out = ""; + let length = 0; + let pendingSpace = false; + for (const ch of raw ?? "") { + const cp = ch.codePointAt(0) ?? 0; + if (hidden(cp)) continue; + if (space(cp)) { + pendingSpace = out.length > 0; + continue; + } + if (pendingSpace) { + if (length + 1 >= CLIENT_NAME_MAX) break; + out += " "; + length += 1; + pendingSpace = false; + } + if (length >= CLIENT_NAME_MAX) break; + out += ch; + length += 1; + } + return out || fallback.slice(0, CLIENT_NAME_MAX); +} + +export function clientKind(row: ClientRowIdentity): ClientKind { + if (row.clientDiscoveryId === "cimd") return "document"; + if (!row.clientDiscoveryId && !row.userId && !row.referenceId) return "self-registered"; + return "server"; +} + +/** Lower-case letters and digits only, for comparing names (`V S-Code` = `vscode`). */ +function folded(text: string): string { + let out = ""; + for (const ch of text.toLowerCase()) + if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch; + return out; +} + +/** The well-known client a self-chosen name contains (`Claude · from claude.ai` → `Claude`). */ +export function lookalikeOf(name: string): string | undefined { + const f = folded(name); + return WELL_KNOWN.find((known) => f.includes(folded(known))); +} + +/** The document host of a `document` client (`claude.ai`), else undefined. */ +export function documentHost(row: ClientRowIdentity): string | undefined { + if (clientKind(row) !== "document") return undefined; + try { + return new URL(row.clientId).host; + } catch { + return undefined; + } +} diff --git a/packages/api/src/auth/oauth-cimd-policy.ts b/packages/api/src/auth/oauth-cimd-policy.ts deleted file mode 100644 index d54f3f82..00000000 --- a/packages/api/src/auth/oauth-cimd-policy.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * RFC-0044 §3: the operator's policy before a Client ID Metadata Document is fetched (the - * plugin's `isMetadataDocumentUrlAllowed`; the transport enforces the network rules). - * - * - `CLOUDHAL_MCP_CLIENT_HOSTS`: when set, the document's host must be on it. - * - A fetch budget across isolates in `rate_limits` (#173): 30 per client origin and 120 in all per - * minute. The plugin's own budget lives in one plugin instance, which a Worker rebuilds on every - * request, so it cannot bound what anonymous `/authorize` calls make the server fetch. - */ -import { APIError } from "better-auth/api"; -import type { StaffStore } from "../staff.ts"; -import { CIMD_FETCH_LIMITS } from "./oauth-registration.ts"; -import { hostMatches } from "./redirect-policy.ts"; - -export async function metadataDocumentAllowed( - staff: StaffStore, - url: string, - clientHosts: readonly string[] | undefined, -): Promise { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return false; - } - if ( - clientHosts && - clientHosts.length > 0 && - !clientHosts.some((p) => hostMatches(parsed.hostname, p)) - ) - return false; - const perOrigin = await staff.hit( - `cloudhal:cimd:origin:${parsed.origin}`, - 60_000, - CIMD_FETCH_LIMITS.perOriginPerMinute, - ); - const overall = - perOrigin && (await staff.hit("cloudhal:cimd:all", 60_000, CIMD_FETCH_LIMITS.perMinute)); - if (!overall) - throw new APIError("TOO_MANY_REQUESTS", { - error: "temporarily_unavailable", - error_description: "too many client metadata fetches; try again in a minute", - }); - return true; -} diff --git a/packages/api/src/testing-mcp-oauth-server.ts b/packages/api/src/testing-mcp-oauth-server.ts index e5f3db5e..87aead5d 100644 --- a/packages/api/src/testing-mcp-oauth-server.ts +++ b/packages/api/src/testing-mcp-oauth-server.ts @@ -88,7 +88,11 @@ export function describeMcpOauthServer(label: string, backend: RolesBackend): vo { 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.client).toEqual({ + id: s.clientId, + name: "Test Agent", + kind: "self-registered", + }); expect(info.body.redirect).toEqual({ host: "127.0.0.1:33418", loopback: true }); expect(info.body.loopbackOnly).toBe(true); expect(info.body.scopes).toEqual([ From d6bb838447b9e5330f269a67785bf5995e017906 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:36:41 +0300 Subject: [PATCH 3/7] fix(api): notify an on-approval release for as long as its request waits (#144) Review of #178, finding 7. A set_embargo from an OAuth connection files an on-approval release, whose execute time is the moment it was filed, so the 60-minute window before it never matched once a millisecond had passed. approvalDue() makes an on-approval release awaiting approval due for as long as it waits. The test now reads notifications three hours after filing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- packages/api/src/routes/notifications.ts | 13 +++++++++++-- packages/api/src/testing-schedules.ts | 4 +++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts index 4a4999cb..93573be7 100644 --- a/packages/api/src/routes/notifications.ts +++ b/packages/api/src/routes/notifications.ts @@ -12,7 +12,7 @@ * (the daily cap was reached), `comments-purge-waiting` (a takedown has waited over 2 minutes for * the live page) and `comments-approval` (an agent's comment request this person can decide). * - schedules (RFC-0008 §14): `schedule-approval-due` (a release awaiting approval runs within 60 - * minutes; for people who may decide it), `schedule-upcoming` (within 15 minutes) and + * minutes, or runs on approval and waits for it; for people who may decide it), `schedule-upcoming` (within 15 minutes) and * `schedule-published` (the last 24 hours) for its creator and approver; `schedule-conflict`, * `schedule-missed`, `schedule-expired` and `schedule-failed` (the last 7 days) for them and for * Editors; `scheduler-stalled` for Administrators and anyone with a release due within 24 hours; @@ -327,6 +327,15 @@ async function commentNotifications(c: Context, now: Date): Promise= t); +} + /** The schedule kinds (RFC-0008 §14), computed from state; nothing is stored. */ async function scheduleNotifications(c: Context, now: Date): Promise { const deps = c.get("schedules"); @@ -366,7 +375,7 @@ async function scheduleNotifications(c: Context, now: Date): Promise= t) { + if (approvalDue(r, due, t)) { const decides = !roles || (r.createdBy.id !== principal.actor.id && diff --git a/packages/api/src/testing-schedules.ts b/packages/api/src/testing-schedules.ts index f6fabfff..c95597e2 100644 --- a/packages/api/src/testing-schedules.ts +++ b/packages/api/src/testing-schedules.ts @@ -545,7 +545,9 @@ export function describeSchedulesApi(label: string, factory: SchedulesApiFactory )) as { status: string; approvalId: string; release: Release }; expect(result.status).toBe("approval-pending"); expect(result.release.state).toBe("awaiting_approval"); - // the people who may decide it are told (§14), the agent's own member included + // the people who may decide it are told (§14), the agent's own member included, for as + // long as the request waits (not only in the minute it was filed) + clock.now = new Date(NOW.getTime() + 3 * HOUR); const kinds = ((await call("/api/notifications", "editor")).body.items as Json[]).map( (n) => n.kind, ); From be319b8feee72cf48f1175728d58bbc0e8be2c7f Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:36:41 +0300 Subject: [PATCH 4/7] fix(api): bound CIMD clients, accept native redirect schemes, check the host list and DCR switch at authorize (#144) Review of #178, findings 1-5. 1. Document clients are capped (5,000; 503 temporarily_unavailable for a new one beyond it) and pruned after a day with no connection and no fetch (pruneCimdClients); the hourly oauth.clients.prune job prunes DCR and document clients in alternate hours, on the :17 watermark. 2. cimd-discovery.ts wraps the plugin's discovery: a stored client validated within 60 min is served from its row with no fetch on every runtime; budgets are spent only before a real fetch (20/min per trusted caller address; 30/min per origin and 120/min in all only for documents not stored yet); a stored client is served from its row when a budget is spent. oauth-cimd-policy.ts is removed. 3. Private-use redirect schemes are accepted for native and public clients when reverse-domain or known (cursor, vscode, CLOUDHAL_MCP_REDIRECT_SCHEMES), matched exactly; dangerous schemes and http look-alikes are always refused; one bad entry refuses the whole registration. Cursor 3.14.27's observed DCR body registers. 4. validateRedirectUri applies CLOUDHAL_MCP_CLIENT_HOSTS to every client at every authorization; document hosts off the list resolve to none. 5. With CLOUDHAL_MCP_DCR=off, /oauth2/authorize refuses clients DCR stored earlier; their existing connections keep working. New shared suite testing-mcp-oauth-bounds.ts on sqlite and D1; each case fails on d3631603. Hardening guide, self-host table, compose and .env.example, ADR-0045 and the changeset updated. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/mcp-oauth-client-registration.md | 2 +- deploy/docker/.env.example | 2 + deploy/docker/compose.yml | 1 + docs/adr/0045-mcp-authorization-server.md | 11 +- docs/guides/cloudflare-hardening.md | 16 +- docs/guides/self-host.md | 3 +- packages/api/src/auth.ts | 9 +- packages/api/src/auth/cimd-discovery.ts | 130 ++++++++ packages/api/src/auth/dcr-server.ts | 5 +- packages/api/src/auth/dcr.ts | 22 +- packages/api/src/auth/instance.ts | 21 +- packages/api/src/auth/oauth-client-rows.ts | 102 ++++++ packages/api/src/auth/oauth-hooks.ts | 49 ++- packages/api/src/auth/oauth-registration.ts | 116 +++---- packages/api/src/auth/oauth.ts | 37 +-- packages/api/src/auth/redirect-policy.ts | 108 +++++- packages/api/src/oauth-prune-job.ts | 29 +- packages/api/src/testing-mcp-oauth-bounds.ts | 312 ++++++++++++++++++ .../api/src/testing-mcp-oauth-registration.ts | 49 +-- .../api/test/mcp-oauth-registration.test.ts | 14 +- packages/cloudflare/src/admin-worker.ts | 2 + 21 files changed, 874 insertions(+), 166 deletions(-) create mode 100644 packages/api/src/auth/cimd-discovery.ts create mode 100644 packages/api/src/auth/oauth-client-rows.ts create mode 100644 packages/api/src/testing-mcp-oauth-bounds.ts diff --git a/.changeset/mcp-oauth-client-registration.md b/.changeset/mcp-oauth-client-registration.md index 748fc7ef..f6386d9d 100644 --- a/.changeset/mcp-oauth-client-registration.md +++ b/.changeset/mcp-oauth-client-registration.md @@ -6,4 +6,4 @@ "@cloudhal/core-ts": patch --- -Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Redirect URIs must be https or loopback http (any port on loopback). `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off` and `CLOUDHAL_MCP_CLIENT_HOSTS` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied. `safeFetch` results carry the response headers. +Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Document clients are fetched at most hourly per client, with budgets that never lock out stored clients, a cap of 5,000 and pruning after a day unused. Redirect URIs must be https, loopback http (any port) or a native app's private-use scheme (`cursor`, `vscode`, reverse-domain, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), and the client host list applies at every authorization. With DCR off, the clients it registered earlier cannot start new authorizations. The consent screen shows client names plain and short, a document client's host first, and marks self-registered clients as unverified. `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off`, `CLOUDHAL_MCP_CLIENT_HOSTS` and `CLOUDHAL_MCP_REDIRECT_SCHEMES` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied, and the request is notified while it waits. `safeFetch` results carry the response headers. diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index e1943a74..291ae1ce 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -55,6 +55,8 @@ CLOUDHAL_MCP_OAUTH=on CLOUDHAL_MCP_CIMD=on CLOUDHAL_MCP_DCR=on CLOUDHAL_MCP_CLIENT_HOSTS= +# Extra private-use redirect schemes of native apps, comma-separated (cursor and vscode are known). +CLOUDHAL_MCP_REDIRECT_SCHEMES= # Secrets, each passed to the container only when set here or in the shell. # Better Auth's secret is created on the volume at first start (/data/.cloudhal/auth-secret) and diff --git a/deploy/docker/compose.yml b/deploy/docker/compose.yml index 6a6bcf96..5dfca8a9 100644 --- a/deploy/docker/compose.yml +++ b/deploy/docker/compose.yml @@ -30,6 +30,7 @@ services: - CLOUDHAL_MCP_CIMD=${CLOUDHAL_MCP_CIMD:-on} - CLOUDHAL_MCP_DCR=${CLOUDHAL_MCP_DCR:-on} - CLOUDHAL_MCP_CLIENT_HOSTS=${CLOUDHAL_MCP_CLIENT_HOSTS:-} + - CLOUDHAL_MCP_REDIRECT_SCHEMES=${CLOUDHAL_MCP_REDIRECT_SCHEMES:-} # Secrets. A name without a value is passed through from .env or the shell only when it is # set there, so an unset BETTER_AUTH_SECRET leaves the container with the secret it created # on the volume (/data/.cloudhal/auth-secret). Never set it to an empty value. diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index 991518d9..f8b01399 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -16,12 +16,13 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve 6. **Registration (Phase 3a, amended 2026-11-09).** Phase 2 had none and a server-side test client (`OauthServer.createClient`); Phase 3a replaces it with the two paths of RFC-0044 §3, and `createClient` is gone (the suites register through DCR or CIMD; `clientPrivileges` now admits nobody). See "Phase 3a" below. ## Phase 3a: client registration (2026-11-09) -1. **CIMD** is `@better-auth/cimd` 1.7.18's `cimd()` with the MCP 2026-07-28 profile, behind a Cloudhal transport (`cimdFetch`) over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile, and caches per plugin instance (500 entries, at most 60 min). A Worker builds the instance per request, so the fetch budget that bounds amplification lives in `rate_limits` (30 per origin and 120 in all per minute, `oauth-cimd-policy.ts`). `CLOUDHAL_MCP_CIMD=off` turns it off. -2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers, with every provider row of theirs in one batch. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`. -3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host, or http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching), at most 10; private-use schemes are refused for both paths. The rule runs at registration (DCR) and at every authorization (`validateRedirectUri`), so a CIMD document's other URIs are stored but never used. -4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from and a DCR client may redirect to (`localhost` admits loopback). A value that does not parse turns registration off. +1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile. Around it: a stored client validated within 60 min (its row's `updatedAt`) is used without a fetch on every runtime (a Worker rebuilds the plugin and its cache per request); fetches spend budgets in `rate_limits` — 20 per caller address per minute, and only for documents not stored yet 30 per origin and 120 in all — and a stored client is served from its row when a budget is spent (stale-if-exhausted), so anonymous calls cannot lock members' clients out; at most 5,000 document clients are stored (503), and one with no connection and no fetch for a day is pruned. A failed refetch of a stored client is refused. `CLOUDHAL_MCP_CIMD=off` turns it off. +2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers (document clients after a day), with every provider row of theirs in one batch per kind. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`, and `/oauth2/authorize` refuses the clients DCR stored earlier; connections they already have keep working until revoked or pruned. +3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host; http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching); or a native app's private-use scheme (`scheme:/…`, matched exactly) that is reverse-domain or a known app's (`cursor`, `vscode`, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), never `javascript`/`data`/`file`/`vbscript`/`about`/`blob` and friends or an `http` look-alike; at most 10, one refused entry refusing the registration. The rules and the host list run at registration (DCR) and at every authorization (`validateRedirectUri`). +4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from (stored clients included) and every client may redirect to (`localhost` admits loopback; private-use redirects then need a known scheme). A value that does not parse turns registration off. 5. **AS metadata** advertises `client_id_metadata_document_supported` with CIMD on, `registration_endpoint` with DCR on, and `token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]` (the provider's shared-secret methods are dropped: no client here holds a secret). -6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; a person approves it in Studio and it goes live with their approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken. +6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; it is notified (`schedule-approval-due`) for as long as it waits, and a person approves it in Studio with their own approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken. +7. **Consent identity** (`client-identity.ts`): a client name is shown plain (control, bidi and zero-width characters removed, spaces collapsed) and at most 100 characters; a document client leads with its host, in the title too; a DCR client is "self-registered, not verified", flagged when its name contains a well-known client's. ## Alternatives considered | Alternative | Pros | Cons | Why not | diff --git a/docs/guides/cloudflare-hardening.md b/docs/guides/cloudflare-hardening.md index ff24f825..95bae3ed 100644 --- a/docs/guides/cloudflare-hardening.md +++ b/docs/guides/cloudflare-hardening.md @@ -84,9 +84,10 @@ Claude, ChatGPT, Cursor, VS Code and the MCP Inspector connect to `https://-admin ``` -On Node (`cloudhal studio`, the Docker image) they are environment variables; `compose.yml` passes all four through from `.env`. +On Node (`cloudhal studio`, the Docker image) they are environment variables; `compose.yml` passes all five through from `.env`. ### What a client can and cannot do -- **Metadata documents (CIMD).** The `client_id` must be an HTTPS URL with a path, and the document there must name exactly that URL as its `client_id`. The server fetches it with the same SSRF-safe fetcher media imports use: HTTPS on port 443, DNS names only (no IP addresses), every resolved address public (on Node the connection is pinned to the checked address), no redirects followed, at most 5 KB, 5 seconds. It never fetches from the admin Worker's own host or a site's domain. Fetches are budgeted across isolates: 30 a minute per client origin and 120 a minute in all. +- **Metadata documents (CIMD).** The `client_id` must be an HTTPS URL with a path, and the document there must name exactly that URL as its `client_id`. The server fetches it with the same SSRF-safe fetcher media imports use: HTTPS on port 443, DNS names only (no IP addresses), every resolved address public (on Node the connection is pinned to the checked address), no redirects followed, at most 5 KB, 5 seconds. It never fetches from the admin Worker's own host or a site's domain. A stored client is used from its row for an hour after each fetch, on every runtime, so members' sign-ins and token refreshes cost no fetch. Fetches are budgeted across isolates: 20 a minute per caller address, and for documents not stored yet also 30 a minute per client origin and 120 a minute in all. When a budget is spent, a stored client is served from its row and only new documents are refused (`429`), so anonymous calls cannot lock members' clients out. At most 5,000 document clients are stored (`503` beyond); one nobody connected through or fetched for a day is deleted by the hourly job, and its next authorization stores it again. +- **The consent screen.** A metadata-document client is named by its document's host, in the page title and first; its self-chosen name is shown plain (control, bidirectional and zero-width characters removed) and at most 100 characters long. A dynamically registered client is labelled "Self-registered, not verified", with a warning when its name looks like a well-known client's (Claude, Claude Code, ChatGPT, Codex, VS Code, Cursor). - **Dynamic registration (DCR).** Anonymous, so it is limited: 10 registrations per client address per hour and 300 per installation per day (invalid attempts count too), at most 5,000 registered clients (`503` beyond), a body of at most 5 KB. Only public clients register: no client secret is ever issued. A client's name (1–100 characters), up to 10 redirect URIs, and the grant and response types are kept; logos, URIs, contacts and keys are dropped, never stored or shown. A client nobody connected, used or disconnected in 30 days is deleted by the scheduler's hourly job; a client with a live connection is never pruned. -- **Redirect URIs.** `https://` on a public host, or `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, where the port may change between runs (RFC 8252 §7.3). Everything else must match the registered URI exactly. Custom schemes (`cursor://…`) are refused. -- **The host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a metadata document must be served from a listed host, and a dynamically registered client may redirect only to listed hosts. Loopback redirects then need `localhost` on the list. The list applies to new registrations and to every authorization, so removing a host cuts off its clients at their next sign-in. A list that does not parse turns client registration off, and the log says why. -- **Turning DCR off.** When every client your members use has a metadata document (Claude, Claude Code, Codex and VS Code do), `CLOUDHAL_MCP_DCR=off` removes the only anonymous write endpoint. Clients registered earlier keep their connections and age out like any other. +- **Redirect URIs.** `https://` on a public host; `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, where the port may change between runs (RFC 8252 §7.3); or a native app's private-use scheme (RFC 8252 §7.1), written `scheme:/…`, when the scheme is reverse-domain (`com.example.app:/callback`) or a known app's (`cursor://anysphere.cursor-mcp/oauth/callback`, `vscode://…`, plus `CLOUDHAL_MCP_REDIRECT_SCHEMES`). Everything but the loopback port must match the registered URI exactly. Schemes a browser or the system acts on (`javascript`, `data`, `file`, `vbscript`, `about`, `blob`, …) and look-alikes of `http` are always refused, and one refused URI refuses the whole registration. +- **The host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a metadata document must be served from a listed host, and every client, however it registered, may send members back only to listed hosts. Loopback redirects then need `localhost` on the list, and private-use redirects a known app scheme (reverse-domain schemes are refused). The list applies to new registrations and to every authorization, so removing a host cuts off its clients at their next sign-in; their existing connections keep working until revoked. A list that does not parse turns client registration off, and the log says why. +- **Turning DCR off.** When every client your members use has a metadata document (Claude, Claude Code, Codex and VS Code do), `CLOUDHAL_MCP_DCR=off` removes the anonymous registration endpoint. Clients it registered earlier can no longer start an authorization (for any member), but the connections they already have keep working, refreshes included, until a member disconnects them in Studio or they are pruned 30 days after their last use. Revoke them there to end them at once. ### Rotating `BETTER_AUTH_SECRET` diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md index 07d8c785..83c4e7ff 100644 --- a/docs/guides/self-host.md +++ b/docs/guides/self-host.md @@ -80,7 +80,8 @@ All of these are read by `cloudhal studio` in the container, except those marked | `CLOUDHAL_AUTOMATIONS` | `on` | `off` stops scheduled automations; releases and gates go on ([schedules](schedules.md)). | | `CLOUDHAL_TRUSTED_ORIGINS` | empty | Extra origins the API accepts browser requests from, comma-separated. | | `CLOUDHAL_MCP_OAUTH`, `CLOUDHAL_MCP_CIMD`, `CLOUDHAL_MCP_DCR` | `on` | `off` turns off OAuth for remote MCP clients, or only one way clients register: metadata documents (CIMD) or dynamic registration (DCR). OAuth needs `CLOUDHAL_BASE_URL`. See [hardening](cloudflare-hardening.md#remote-mcp-clients-oauth). | -| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Hosts OAuth clients may come from, comma-separated (`claude.ai,*.example.com`, `localhost` for loopback redirects). Empty: any host. | +| `CLOUDHAL_MCP_CLIENT_HOSTS` | empty | Hosts OAuth clients may come from and send members back to, comma-separated (`claude.ai,*.example.com`, `localhost` for loopback redirects). Empty: any host. | +| `CLOUDHAL_MCP_REDIRECT_SCHEMES` | empty | Extra private-use redirect schemes of native apps, comma-separated (`cursor` and `vscode` are known already). | | `BETTER_AUTH_SECRET` | created on the volume | Better Auth's secret. Unset, the container creates one at first start and keeps it at `/data/.cloudhal/auth-secret`; set it only to supply your own, and never empty. | | `CLOUDHAL_AI_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | empty | Workers AI over REST for Build chat (both or neither). The token is kept in the container's memory, never written to the volume. | diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index b6c57144..4ceca3e6 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -31,13 +31,20 @@ export { oauthEndpoints, } from "./auth/oauth.ts"; export { + pruneCimdClients, + pruneDcrClients, + pruneUnusedClients, +} from "./auth/oauth-client-rows.ts"; +export { + CIMD_LIMITS, + type CimdLimits, DCR_LIMITS, type DcrLimits, type OauthRegistration, type OauthRegistrationEnv, oauthRegistrationFrom, parseClientHosts, - pruneDcrClients, + parseRedirectSchemes, } from "./auth/oauth-registration.ts"; export { ADDABLE_KEY_SCOPES, diff --git a/packages/api/src/auth/cimd-discovery.ts b/packages/api/src/auth/cimd-discovery.ts new file mode 100644 index 00000000..cf993bd4 --- /dev/null +++ b/packages/api/src/auth/cimd-discovery.ts @@ -0,0 +1,130 @@ +/** + * RFC-0044 §3: Client ID Metadata Documents, with Cloudhal's bounds around `@better-auth/cimd`'s + * discovery. The plugin keeps its validation cache in one plugin instance, which a Worker builds + * for every request, so on its own it would fetch a stored client's document on every + * `getClient` (authorize, consent, every token refresh), and anonymous callers could make it + * store a client row for every new URL. Around the plugin's `resolve`: + * + * 1. **Host list.** With `CLOUDHAL_MCP_CLIENT_HOSTS` set, a document host off the list is refused, + * stored or not (a stored one resolves to nothing, so it cannot authorize). + * 2. **Stored and fresh: no fetch.** A stored client whose row was validated within + * `freshMinutes` (the row's `updatedAt`, which the plugin sets on every fetch) is used as it + * is, on every runtime: no network, no budget. + * 3. **Cap.** A document not stored yet is refused (`temporarily_unavailable`, 503) once + * `maxClients` document clients exist; the hourly job prunes unused ones + * (`oauth-client-rows.ts`). + * 4. **Budgets** in `rate_limits`, spent only before a real fetch: every fetch counts against the + * caller's address (the runtime's trusted header, #165/#169); a document not stored yet also + * against its origin and the installation. A stored client is never blocked by them: when a + * budget is spent it is served from its row (stale-if-exhausted), so anonymous calls that use + * up a budget cannot stop members' clients from connecting or refreshing. + * + * Then the plugin fetches and validates (`client_id` = URL, the MCP profile, size, media type) and + * stores or refreshes the row. A failed refetch of a stored client is refused, not served stale: + * a document that disappeared or changed badly ends the client. + */ +import { type CimdOptions, createCimdClientDiscovery } from "@better-auth/cimd"; +import type { GenericEndpointContext } from "@better-auth/core"; +import { + type ClientDiscovery, + type ClientMetadataResourceFetch, + extendOAuthProvider, +} from "@better-auth/oauth-provider"; +import type { BetterAuthPlugin } from "better-auth"; +import { APIError } from "better-auth/api"; +import type { CimdLimits } from "./oauth-registration.ts"; +import { hostMatches } from "./redirect-policy.ts"; + +export interface CimdGuard { + readonly limits: CimdLimits; + readonly clientHosts?: readonly string[] | undefined; + /** Stored document clients (`countCimdClients`). */ + readonly count: () => Promise; + /** One hit of a fixed-window counter (`StaffStore.hit`); false once `max` is passed. */ + readonly hit: (key: string, windowMs: number, max: number) => Promise; + /** The caller's address from the runtime's trusted header. */ + readonly address: (headers: Headers | undefined) => string; + readonly now: () => Date; +} + +type StoredClient = Parameters[2]; + +const unavailable = (status: "TOO_MANY_REQUESTS" | "SERVICE_UNAVAILABLE", description: string) => + new APIError(status, { error: "temporarily_unavailable", error_description: description }); + +function updatedAtOf(client: NonNullable): number { + const value = (client as { updatedAt?: Date | string | number | null }).updatedAt; + return value === null || value === undefined ? Number.NaN : new Date(value).getTime(); +} + +/** Spends one fetch: the caller's budget, and for a new document its origin's and the total. */ +async function spend(guard: CimdGuard, url: URL, stored: boolean, address: string) { + const { limits } = guard; + if (!(await guard.hit(`cloudhal:cimd:addr:${address}`, 60_000, limits.perAddressPerMinute))) + return false; + if (stored) return true; + return ( + (await guard.hit(`cloudhal:cimd:origin:${url.origin}`, 60_000, limits.perOriginPerMinute)) && + (await guard.hit("cloudhal:cimd:all", 60_000, limits.perMinute)) + ); +} + +async function guardedResolve( + inner: ClientDiscovery, + guard: CimdGuard, + input: { ctx: GenericEndpointContext; clientId: string; existing: StoredClient }, +): Promise { + const { ctx, clientId, existing } = input; + if (!inner.matches(clientId)) return null; + const url = new URL(clientId); + const hosts = guard.clientHosts; + if (hosts && hosts.length > 0 && !hosts.some((p) => hostMatches(url.hostname, p))) { + if (existing) return null; + throw new APIError("BAD_REQUEST", { + error: "invalid_client", + error_description: "client_id URL is not on this server's list of client hosts", + }); + } + const freshMs = guard.limits.freshMinutes * 60_000; + if (existing && guard.now().getTime() - updatedAtOf(existing) < freshMs) return existing; + if (!existing && (await guard.count()) >= guard.limits.maxClients) + throw unavailable( + "SERVICE_UNAVAILABLE", + "this server stores no more clients right now; try again later", + ); + if (!(await spend(guard, url, Boolean(existing), guard.address(ctx.headers)))) { + if (existing) return existing; + throw unavailable( + "TOO_MANY_REQUESTS", + "too many client metadata fetches; try again in a minute", + ); + } + return inner.resolve(ctx, clientId, existing); +} + +/** + * The `cimd()` plugin with Cloudhal's guard (same id, MCP 2026-07-28 profile, the runtime's + * transport). Each instance keeps the plugin's bounded cache (500 entries). + */ +export function guardedCimd( + fetch: ClientMetadataResourceFetch, + guard: CimdGuard, +): BetterAuthPlugin { + const options: CimdOptions = { + fetchClientMetadataResource: fetch, + metadataProfile: "mcp-2026-07-28", + metadataRevalidationInterval: guard.limits.freshMinutes * 60, + maxCacheEntries: 500, + }; + const inner = createCimdClientDiscovery(options); + const discovery: ClientDiscovery = { + ...inner, + resolve: (ctx, clientId, existing) => guardedResolve(inner, guard, { ctx, clientId, existing }), + }; + return { + id: "cimd", + init(ctx) { + extendOAuthProvider(ctx, { clientDiscovery: discovery }); + }, + } as BetterAuthPlugin; +} diff --git a/packages/api/src/auth/dcr-server.ts b/packages/api/src/auth/dcr-server.ts index fddb5a17..bbbf87de 100644 --- a/packages/api/src/auth/dcr-server.ts +++ b/packages/api/src/auth/dcr-server.ts @@ -55,7 +55,10 @@ export function oauthClientRegistration( sql: d.sql, staff: d.staffStore, limits: dcrLimits(registration), - clientHosts: registration.clientHosts, + policy: { + clientHosts: registration.clientHosts, + schemes: registration.redirectSchemes, + }, register, }, input, diff --git a/packages/api/src/auth/dcr.ts b/packages/api/src/auth/dcr.ts index 43985c25..874029ce 100644 --- a/packages/api/src/auth/dcr.ts +++ b/packages/api/src/auth/dcr.ts @@ -7,8 +7,9 @@ * 1. rate limits in `rate_limits` (#173): 10 per client address per hour and 300 per installation * per day, counted for every attempt, valid or not; * 2. at most 5,000 registered clients (503 beyond it), pruned after 30 unused days; - * 3. the metadata: `client_name` 1–100 characters; `redirect_uris` 1–10, each https or loopback - * http (and on `CLOUDHAL_MCP_CLIENT_HOSTS` when set); public clients only + * 3. the metadata: `client_name` 1–100 characters; `redirect_uris` 1–10, each https, loopback + * http or a native app's private-use scheme (`redirect-policy.ts`; on + * `CLOUDHAL_MCP_CLIENT_HOSTS` when set), one bad entry refusing the whole request; public clients only * (`token_endpoint_auth_method` `none`, also when absent: no secret is ever minted); * `grant_types` ⊆ {`authorization_code`, `refresh_token`} with `authorization_code`; * `response_types` = [`code`]. Only these fields, `application_type` and `scope` reach the @@ -19,8 +20,9 @@ import type { SqlDriver } from "@cloudhal/storage"; import type { StaffStore } from "../staff.ts"; -import { countDcrClients, type DcrLimits } from "./oauth-registration.ts"; -import { redirectUrisProblem } from "./redirect-policy.ts"; +import { countDcrClients } from "./oauth-client-rows.ts"; +import type { DcrLimits } from "./oauth-registration.ts"; +import { type RedirectPolicy, redirectUrisProblem } from "./redirect-policy.ts"; /** RFC-0044 §3: "Body ≤ 5 KB". */ export const DCR_MAX_BODY_BYTES = 5 * 1024; @@ -46,17 +48,14 @@ const invalid = (description: string) => new RegistrationError(400, "invalid_client_metadata", description); /** The body the provider registers: the checked fields only. */ -export function registrationBody( - raw: unknown, - clientHosts: readonly string[] | undefined, -): Record { +export function registrationBody(raw: unknown, policy: RedirectPolicy): Record { if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw invalid("the body must be a JSON object"); const m = raw as Record; const name = typeof m.client_name === "string" ? m.client_name.trim() : ""; if (name.length === 0 || name.length > NAME_MAX) throw invalid(`client_name is required (1–${NAME_MAX} characters)`); - const redirect = redirectUrisProblem(m.redirect_uris, clientHosts); + const redirect = redirectUrisProblem(m.redirect_uris, policy); if (redirect) throw new RegistrationError(400, "invalid_redirect_uri", redirect); const auth = m.token_endpoint_auth_method ?? "none"; if (auth !== "none") @@ -100,7 +99,8 @@ export interface DcrDeps { readonly sql: SqlDriver; readonly staff: StaffStore; readonly limits: DcrLimits; - readonly clientHosts?: readonly string[] | undefined; + /** Redirect rules: the host list and the extra private-use schemes. */ + readonly policy: RedirectPolicy; /** The provider's server-side `registerOAuthClient` (no HTTP, no session). */ readonly register: (body: Record) => Promise>; } @@ -129,7 +129,7 @@ export async function registerDcrClient( } catch { throw invalid("the body must be JSON"); } - const body = registrationBody(raw, d.clientHosts); + const body = registrationBody(raw, d.policy); if ((await countDcrClients(d.sql)) >= limits.maxClients) throw new RegistrationError( 503, diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 73abe49b..5319bddb 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -7,8 +7,9 @@ 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 { metadataDocumentAllowed } from "./oauth-cimd-policy.ts"; +import { countCimdClients } from "./oauth-client-rows.ts"; import { oauthBeforeHook } from "./oauth-hooks.ts"; +import { cimdLimits } from "./oauth-registration.ts"; import { API_KEY_HEADER, type AuthOptions, @@ -74,8 +75,15 @@ export function createInstance( const oauth = options.mcpOauth ? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, { grantOf: async (userId, clientId) => (await internal.grants.live(userId, clientId))?.id, - metadataDocumentAllowed: (url) => - metadataDocumentAllowed(staff, url, options.mcpOauth?.registration?.clientHosts), + cimdGuard: { + limits: cimdLimits(options.mcpOauth.registration), + clientHosts: options.mcpOauth.registration?.clientHosts, + count: () => countCimdClients(options.sql), + hit: (key, windowMs, max) => staff.hit(key, windowMs, max), + // The runtime's trusted address (#165); without one, one shared bucket as elsewhere. + address: (headers) => headers?.get(ipHeader ?? "cf-connecting-ip") ?? "unknown", + now: clock, + }, }) : []; const instance = betterAuth({ @@ -117,7 +125,12 @@ export function createInstance( throw new APIError("NOT_FOUND", { message: "not found" }); // 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 as never, { grants: internal.grants, staff, now: clock }); + return oauthBeforeHook(ctx as never, { + grants: internal.grants, + staff, + now: clock, + dcr: options.mcpOauth.registration?.dcr === true, + }); 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-client-rows.ts b/packages/api/src/auth/oauth-client-rows.ts new file mode 100644 index 00000000..58b5c8dd --- /dev/null +++ b/packages/api/src/auth/oauth-client-rows.ts @@ -0,0 +1,102 @@ +/** + * RFC-0044 §3: the OAuth client rows anonymous callers can make the server store, and their + * bounds. Dynamic registration (`dcr.ts`) and metadata documents (`cimd-discovery.ts`) each have a + * cap, and the hourly `oauth.clients.prune` job (`oauth-prune-job.ts`) deletes the unused ones with + * every provider row of theirs in one atomic batch per kind. + */ +import type { SqlDriver } from "@cloudhal/storage"; +import { CIMD_LIMITS, DCR_LIMITS } from "./oauth-registration.ts"; + +/** + * A dynamically registered client: no discovery source (CIMD sets `cimd`) and no owner (the + * provider records the member or reference only for registrations made with a session or an + * initial access token, which this server never accepts). + */ +export const dcrClient = (t = "") => + `${t}"clientDiscoveryId" IS NULL AND ${t}"userId" IS NULL AND ${t}"referenceId" IS NULL`; + +/** How many dynamically registered clients exist (the cap, §3). */ +export async function countDcrClients(sql: SqlDriver): Promise { + const row = await sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${dcrClient()}`, + ); + return Number(row?.n ?? 0); +} + +/** The most clients one pruning pass deletes (D1 prefers short statements). */ +export const PRUNE_BATCH = 50; + +/** The clients a metadata document made (`cimd()` stores them under its discovery id). */ +export const cimdClient = (t = "") => `${t}"clientDiscoveryId" = 'cimd'`; + +/** How many document clients are stored (the cap). */ +export async function countCimdClients(sql: SqlDriver): Promise { + const row = await sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${cimdClient()}`, + ); + return Number(row?.n ?? 0); +} + +/** + * Deletes one kind of unused client, at most `PRUNE_BATCH`: no live connection, no connection + * made, used or ended since `cutoff`, and the client's own row older than it (`since`: when it + * was registered, or when its document was last fetched). A client with a live connection is + * never pruned, however idle: ending it is its member's decision ("Connected agents"). Every row + * the provider keeps for the client goes in the same atomic batch, each statement selecting the + * same clients (the predicate reads only `oauthClient` and `agent_grants`, which the batch + * changes last). Returns how many were deleted. + */ +async function pruneKind( + sql: SqlDriver, + kind: { readonly where: (alias: string) => string; readonly since: "createdAt" | "updatedAt" }, + cutoff: string, +): Promise { + const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${PRUNE_BATCH}`; + const params = [cutoff, cutoff, cutoff, cutoff]; + const code = (path: string) => + `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; + const results = await sql.batch([ + { sql: `DELETE FROM "oauthAccessToken" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthRefreshToken" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthConsent" WHERE "clientId" IN (${prunable})`, params }, + { sql: `DELETE FROM "oauthClientResource" WHERE "clientId" IN (${prunable})`, params }, + { + sql: `DELETE FROM "verification" WHERE "value" LIKE '%authorization_code%' AND ${code("$.type")} = 'authorization_code' AND ${code("$.query.client_id")} IN (${prunable})`, + params, + }, + { sql: `DELETE FROM "oauthClient" WHERE "clientId" IN (${prunable})`, params }, + ]); + return results.at(-1)?.changes ?? 0; +} + +const cutoffOf = (now: Date, days: number) => + new Date(now.getTime() - days * 86_400_000).toISOString(); + +/** Dynamically registered clients unused for `unusedDays` since they registered (§3: 30). */ +export function pruneDcrClients( + sql: SqlDriver, + options: { readonly now: Date; readonly unusedDays?: number }, +): Promise { + const days = options.unusedDays ?? DCR_LIMITS.unusedDays; + return pruneKind(sql, { where: dcrClient, since: "createdAt" }, cutoffOf(options.now, days)); +} + +/** + * Document clients nobody connected through and nobody fetched for `unusedDays` (default 1): the + * next authorization fetches the document and stores the client again. + */ +export function pruneCimdClients( + sql: SqlDriver, + options: { readonly now: Date; readonly unusedDays?: number }, +): Promise { + const days = options.unusedDays ?? CIMD_LIMITS.unusedDays; + return pruneKind(sql, { where: cimdClient, since: "updatedAt" }, cutoffOf(options.now, days)); +} + +/** Both kinds, as the hourly job runs them; how many of each were deleted. */ +export async function pruneUnusedClients( + sql: SqlDriver, + options: { readonly now: Date }, +): Promise<{ dcr: number; cimd: number }> { + return { dcr: await pruneDcrClients(sql, options), cimd: await pruneCimdClients(sql, options) }; +} diff --git a/packages/api/src/auth/oauth-hooks.ts b/packages/api/src/auth/oauth-hooks.ts index c8b1a033..5bacb7fd 100644 --- a/packages/api/src/auth/oauth-hooks.ts +++ b/packages/api/src/auth/oauth-hooks.ts @@ -8,6 +8,8 @@ * the member sees Studio's consent screen again. * The request's parameters are read from the query, or from the form body of a POST, as the * provider reads them. + * - `/oauth2/authorize` with `CLOUDHAL_MCP_DCR=off`: a client dynamic registration made earlier + * is refused (no new authorization; connections it already has keep working until revoked). * - `/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 @@ -28,6 +30,8 @@ export interface OauthHookDeps { readonly grants: GrantStore; readonly staff: StaffStore; readonly now: () => Date; + /** Dynamic registration is on (`CLOUDHAL_MCP_DCR`); off, its clients cannot authorize. */ + readonly dcr: boolean; } type Where = { field: string; value: unknown }[]; @@ -55,7 +59,10 @@ interface HookContext { /** 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/authorize") { + await refuseDcrClientWhenOff(ctx, d); + return forgetStaleConsent(ctx, d); + } if (ctx.path === "/oauth2/token") return checkTokenRequest(ctx, d); } @@ -75,13 +82,43 @@ export async function consentIsStale( }); } -async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise { - // 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. +/** + * The authorization request's parameters 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 query. + */ +function authorizeParams(ctx: HookContext): Record { const settings = ctx.authorizeSettings ?? { isAuthorize: true }; const fromBody = ctx.method === "POST" && settings.isAuthorize === true; - const params = (fromBody ? ctx.body : ctx.query) ?? {}; + return (fromBody ? ctx.body : ctx.query) ?? {}; +} + +interface ClientOwnerRow { + clientDiscoveryId?: string | null; + userId?: string | null; + referenceId?: string | null; +} + +/** With DCR off, a client it registered (no discovery source, no owner) starts nothing new. */ +async function refuseDcrClientWhenOff(ctx: HookContext, d: OauthHookDeps): Promise { + if (d.dcr) return; + const clientId = authorizeParams(ctx).client_id; + if (typeof clientId !== "string" || !clientId) return; + const row = await ctx.context.adapter.findOne({ + model: "oauthClient", + where: [{ field: "clientId", value: clientId }], + }); + if (row && !row.clientDiscoveryId && !row.userId && !row.referenceId) + throw new APIError("BAD_REQUEST", { + error: "invalid_client", + error_description: + "dynamic client registration is off on this server, so this client cannot connect", + }); +} + +async function forgetStaleConsent(ctx: HookContext, d: OauthHookDeps): Promise { + const params = authorizeParams(ctx); const clientId = params.client_id; if (typeof clientId !== "string" || !clientId) return; const session = await getSessionFromCtx(ctx as never).catch(() => null); diff --git a/packages/api/src/auth/oauth-registration.ts b/packages/api/src/auth/oauth-registration.ts index 4eeb0139..50bd2d2c 100644 --- a/packages/api/src/auth/oauth-registration.ts +++ b/packages/api/src/auth/oauth-registration.ts @@ -4,15 +4,17 @@ * * - **CIMD** (`CLOUDHAL_MCP_CIMD`, on unless `off`): a client names an HTTPS URL as its * `client_id`; the server fetches the document there through the runtime's SSRF-safe transport - * (`cimd-fetch.ts`). `CLOUDHAL_MCP_CLIENT_HOSTS` limits which hosts may serve one. + * (`cimd-fetch.ts`), at most hourly per client (`cimd-discovery.ts`), with a cap and pruning. * - **DCR** (`CLOUDHAL_MCP_DCR`, on unless `off`): RFC 7591 `POST /api/auth/oauth2/register`, - * public clients only, with rate limits, a cap and pruning (`dcr.ts`, `pruneDcrClients`). + * public clients only, with rate limits, a cap and pruning (`dcr.ts`, `oauth-client-rows.ts`). + * Off, the clients it registered earlier cannot start a new authorization either. + * - `CLOUDHAL_MCP_CLIENT_HOSTS` limits the hosts documents come from and every redirect goes to; + * `CLOUDHAL_MCP_REDIRECT_SCHEMES` adds private-use redirect schemes (`redirect-policy.ts`). * - * With both off, only clients already in the database can connect (none, unless an operator made - * one): an unknown `client_id` is refused at `/oauth2/authorize`. + * With both off, no client registered by either path can start an authorization. */ import type { ClientMetadataResourceFetch } from "@better-auth/oauth-provider"; -import type { SqlDriver } from "@cloudhal/storage"; +import { schemeProblem } from "./redirect-policy.ts"; export interface DcrLimits { /** Registrations per client address per hour (§3: 10). */ @@ -32,11 +34,36 @@ export const DCR_LIMITS: DcrLimits = { unusedDays: 30, }; +export interface CimdLimits { + /** Metadata fetches per caller address per minute, whoever the client is. */ + readonly perAddressPerMinute: number; + /** Fetches of documents not stored yet, per client origin and in all, per minute. */ + readonly perOriginPerMinute: number; + readonly perMinute: number; + /** Clients from metadata documents that may be stored at once (beyond it 503). */ + readonly maxClients: number; + /** A stored document younger than this is used without fetching it again (minutes). */ + readonly freshMinutes: number; + /** A document client with no connection and no fetch for this many days is deleted. */ + readonly unusedDays: number; +} + /** - * Outbound metadata fetches across every isolate (Cloudhal's `rate_limits`, not the plugin's - * per-instance budget, which a Worker rebuilds on every request): per client origin and in all. + * Outbound metadata fetches and stored document clients, counted across every isolate in + * Cloudhal's `rate_limits` (the plugin's own budget lives in one instance, which a Worker rebuilds + * on every request). A stored client is fetched at most once per `freshMinutes`; only documents + * not stored yet spend the shared per-origin budget, so anonymous callers cannot starve the + * clients members already use. The next authorization recreates a pruned client from its + * document, so unused rows go after a day. */ -export const CIMD_FETCH_LIMITS = { perOriginPerMinute: 30, perMinute: 120 } as const; +export const CIMD_LIMITS: CimdLimits = { + perAddressPerMinute: 20, + perOriginPerMinute: 30, + perMinute: 120, + maxClients: 5_000, + freshMinutes: 60, + unusedDays: 1, +}; export interface OauthRegistration { /** CIMD's transport (`cimdFetch(...)`); absent: CIMD is off. */ @@ -48,8 +75,15 @@ export interface OauthRegistration { * client may redirect to (`localhost` admits loopback redirects). Empty: any host. */ readonly clientHosts?: readonly string[] | undefined; + /** + * `CLOUDHAL_MCP_REDIRECT_SCHEMES`: private-use redirect schemes allowed besides the built-in + * ones (`cursor`, `vscode`) and reverse-domain schemes (`redirect-policy.ts`). + */ + readonly redirectSchemes?: readonly string[] | undefined; /** Overrides of `DCR_LIMITS` (tests). */ readonly limits?: Partial | undefined; + /** Overrides of `CIMD_LIMITS` (tests). */ + readonly cimdLimits?: Partial | undefined; } /** The variables both runtimes read (RFC-0044 §10). */ @@ -57,6 +91,7 @@ export interface OauthRegistrationEnv { readonly CLOUDHAL_MCP_CIMD?: string | undefined; readonly CLOUDHAL_MCP_DCR?: string | undefined; readonly CLOUDHAL_MCP_CLIENT_HOSTS?: string | undefined; + readonly CLOUDHAL_MCP_REDIRECT_SCHEMES?: string | undefined; } const off = (value: string | undefined) => value?.trim().toLowerCase() === "off"; @@ -88,6 +123,18 @@ export function parseClientHosts(value: string | undefined): string[] { return hosts; } +/** `CLOUDHAL_MCP_REDIRECT_SCHEMES` as a list; throws on an entry `isPrivateUseScheme` refuses. */ +export function parseRedirectSchemes(value: string | undefined): string[] { + const schemes = (value ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + for (const scheme of schemes) + if (schemeProblem(scheme)) + throw new Error(`CLOUDHAL_MCP_REDIRECT_SCHEMES: "${scheme}" cannot be a redirect scheme`); + return schemes; +} + /** * The registration a runtime runs, from its variables and its CIMD transport. A list of client * hosts that does not parse turns registration off (fail closed) and says why in `problem`. @@ -97,8 +144,10 @@ export function oauthRegistrationFrom( cimd: ClientMetadataResourceFetch, ): OauthRegistration & { readonly problem?: string } { let clientHosts: string[]; + let redirectSchemes: string[]; try { clientHosts = parseClientHosts(env.CLOUDHAL_MCP_CLIENT_HOSTS); + redirectSchemes = parseRedirectSchemes(env.CLOUDHAL_MCP_REDIRECT_SCHEMES); } catch (e) { return { dcr: false, problem: e instanceof Error ? e.message : String(e) }; } @@ -106,6 +155,7 @@ export function oauthRegistrationFrom( ...(off(env.CLOUDHAL_MCP_CIMD) ? {} : { cimd }), dcr: !off(env.CLOUDHAL_MCP_DCR), ...(clientHosts.length > 0 ? { clientHosts } : {}), + ...(redirectSchemes.length > 0 ? { redirectSchemes } : {}), }; } @@ -113,52 +163,6 @@ export function dcrLimits(registration: OauthRegistration | undefined): DcrLimit return { ...DCR_LIMITS, ...registration?.limits }; } -/** - * A dynamically registered client: no discovery source (CIMD sets `cimd`) and no owner (the - * provider records the member or reference only for registrations made with a session or an - * initial access token, which this server never accepts). - */ -export const DCR_CLIENT = `"clientDiscoveryId" IS NULL AND "userId" IS NULL AND "referenceId" IS NULL`; - -/** How many dynamically registered clients exist (the cap, §3). */ -export async function countDcrClients(sql: SqlDriver): Promise { - const row = await sql.get<{ n: number }>( - `SELECT COUNT(*) AS n FROM "oauthClient" WHERE ${DCR_CLIENT}`, - ); - return Number(row?.n ?? 0); -} - -/** The most clients one pruning pass deletes (D1 prefers short statements). */ -export const PRUNE_BATCH = 50; - -/** - * Deletes dynamically registered clients unused for `unusedDays` (§3 "Lifetime"): registered - * before the cutoff, no live connection, and no connection made, used or ended since the cutoff. - * A client with a live connection is never pruned, however idle: ending it is its member's - * decision ("Connected agents"). Every row the provider keeps for the client goes in the same - * atomic batch, each statement selecting the same clients (the predicate reads only - * `oauthClient` and `agent_grants`, which the batch changes last). Returns how many were deleted. - */ -export async function pruneDcrClients( - sql: SqlDriver, - options: { readonly now: Date; readonly unusedDays?: number }, -): Promise { - const days = options.unusedDays ?? DCR_LIMITS.unusedDays; - const cutoff = new Date(options.now.getTime() - days * 86_400_000).toISOString(); - const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE c."clientDiscoveryId" IS NULL AND c."userId" IS NULL AND c."referenceId" IS NULL AND c."createdAt" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."createdAt", c."clientId" LIMIT ${PRUNE_BATCH}`; - const params = [cutoff, cutoff, cutoff, cutoff]; - const code = (path: string) => - `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; - const results = await sql.batch([ - { sql: `DELETE FROM "oauthAccessToken" WHERE "clientId" IN (${prunable})`, params }, - { sql: `DELETE FROM "oauthRefreshToken" WHERE "clientId" IN (${prunable})`, params }, - { sql: `DELETE FROM "oauthConsent" WHERE "clientId" IN (${prunable})`, params }, - { sql: `DELETE FROM "oauthClientResource" WHERE "clientId" IN (${prunable})`, params }, - { - sql: `DELETE FROM "verification" WHERE "value" LIKE '%authorization_code%' AND ${code("$.type")} = 'authorization_code' AND ${code("$.query.client_id")} IN (${prunable})`, - params, - }, - { sql: `DELETE FROM "oauthClient" WHERE "clientId" IN (${prunable})`, params }, - ]); - return results.at(-1)?.changes ?? 0; +export function cimdLimits(registration: OauthRegistration | undefined): CimdLimits { + return { ...CIMD_LIMITS, ...registration?.cimdLimits }; } diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts index 403102d5..ff764bb1 100644 --- a/packages/api/src/auth/oauth.ts +++ b/packages/api/src/auth/oauth.ts @@ -19,11 +19,11 @@ * * Worker-safe: the plugins and `jose` use WebCrypto and no `eval`. */ -import { cimd } from "@better-auth/cimd"; 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"; +import { type CimdGuard, guardedCimd } from "./cimd-discovery.ts"; import type { OauthRegistration } from "./oauth-registration.ts"; import { redirectProblem } from "./redirect-policy.ts"; @@ -99,8 +99,9 @@ export function oauthPlugins( options: McpOauthServerOptions, hooks: OauthServerHooks, ): BetterAuthPlugin[] { - const { grantOf, metadataDocumentAllowed } = hooks; + const { grantOf, cimdGuard } = hooks; const registration = options.registration; + const policy = { clientHosts: registration?.clientHosts, schemes: registration?.redirectSchemes }; const dcr = registration?.dcr === true; return [ jwt({ @@ -131,10 +132,11 @@ export function oauthPlugins( // No member creates, edits or links clients through the session-backed endpoints. clientPrivileges: () => false, resourcePrivileges: () => false, - // §6 "Open redirect": the registered URI must match (loopback without the port) and be - // https or loopback http, whichever way the client registered. + // §6 "Open redirect": the registered URI must match (loopback without the port, a + // private-use scheme exactly) and pass the redirect rules and the host list now, whichever + // way and whenever the client registered. validateRedirectUri: (uri, _registered, matched) => - matched && redirectProblem(uri) === undefined, + matched && redirectProblem(uri, policy) === undefined, // 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) }, @@ -152,20 +154,10 @@ export function oauthPlugins( }, ], }), - // §3: CIMD with the runtime's transport and the MCP 2026-07-28 profile (`client_name` and - // `redirect_uris` required). Each plugin instance keeps its own bounded cache (per process on - // Node; per request on Workers, where nothing async may outlive a request). - ...(registration?.cimd - ? [ - cimd({ - fetchClientMetadataResource: registration.cimd, - metadataProfile: "mcp-2026-07-28", - metadataRevalidationInterval: "60m", - maxCacheEntries: 500, - isMetadataDocumentUrlAllowed: (url) => metadataDocumentAllowed(url), - }), - ] - : []), + // §3: CIMD with the runtime's transport and the MCP 2026-07-28 profile, inside Cloudhal's + // guard: stored clients served from their row within the freshness window, a cap on stored + // clients, and fetch budgets that never block a stored client (`cimd-discovery.ts`). + ...(registration?.cimd ? [guardedCimd(registration.cimd, cimdGuard)] : []), ]; } @@ -173,11 +165,8 @@ export function oauthPlugins( export interface OauthServerHooks { /** The id of the member's live grant for a client, if any. */ readonly grantOf: (userId: string, clientId: string) => Promise; - /** - * Before a metadata document is fetched: the host allow-list and the installation-wide fetch - * budget (`rate_limits`). False refuses the `client_id`; a 429 error refuses for now. - */ - readonly metadataDocumentAllowed: (url: string) => Promise; + /** CIMD's bounds: the host list, the cap and the fetch budgets (`cimd-discovery.ts`). */ + readonly cimdGuard: CimdGuard; } /** The access-token claim that names the grant (RFC-0044 §5; a private claim name, RFC 7519 §4.3). */ diff --git a/packages/api/src/auth/redirect-policy.ts b/packages/api/src/auth/redirect-policy.ts index 834f0661..6758d397 100644 --- a/packages/api/src/auth/redirect-policy.ts +++ b/packages/api/src/auth/redirect-policy.ts @@ -4,11 +4,18 @@ * * - `https://` on a host that is not loopback, or `http://` on exactly `localhost`, `127.0.0.1` or * `[::1]` (RFC 8252 §7.3; the port is free there: the provider matches loopback redirects - * without the port, §8.3). Nothing else: no private-use schemes, no plain HTTP elsewhere, no - * fragment, no userinfo, at most 2,048 characters. - * - With `CLOUDHAL_MCP_CLIENT_HOSTS` set (`clientHosts`), a dynamically registered client's - * redirect host must be on it (`claude.ai` or `*.example.com`); a loopback redirect then needs - * `localhost` on the list. CIMD clients are matched on their document's host instead. + * without the port, §8.3). + * - A native app's private-use scheme (RFC 8252 §7.1), matched exactly: `scheme:/…` where the + * scheme is reverse-domain (has a dot, `com.example.app:/cb`) or on the short list of known + * apps (`cursor`, `vscode`) plus `CLOUDHAL_MCP_REDIRECT_SCHEMES`. Never a scheme a browser or + * the system runs or reads (`javascript`, `data`, `file`, `blob`, …) or one that looks like + * http. Every client here is public or native, which RFC 8252 §8.4 requires of such schemes. + * - Nothing else: no plain HTTP elsewhere, no fragment, no userinfo, at most 2,048 characters. + * - With `CLOUDHAL_MCP_CLIENT_HOSTS` set (`clientHosts`), every redirect host must be on it + * (`claude.ai` or `*.example.com`): a loopback redirect then needs `localhost` on the list, and a + * private-use redirect a scheme of the known list (reverse-domain schemes are refused). + * - The rules run at registration (DCR) and at every authorization (`validateRedirectUri`), so a + * host taken off the list, or a document's refused URI, is never used. * * Linear checks only (AGENTS.md rule 13): the URL parser, `indexOf` and comparisons. */ @@ -47,11 +54,72 @@ export function hostMatches(host: string, pattern: string): boolean { return h === p; } -/** Why a redirect URI is refused, or `undefined` when it may be used. */ -export function redirectProblem( +/** Private-use schemes of well-known native MCP clients (Cursor, VS Code). */ +export const KNOWN_REDIRECT_SCHEMES: readonly string[] = ["cursor", "vscode"]; + +/** Schemes a browser or the system acts on itself: never a redirect target. */ +const DANGEROUS_SCHEMES: ReadonlySet = new Set([ + "javascript", + "vbscript", + "data", + "file", + "about", + "blob", + "filesystem", + "view-source", + "jar", + "intent", + "chrome", + "mailto", + "tel", + "sms", + "ftp", +]); + +/** Why `scheme` (lower case, no `:`) can never be a private-use redirect scheme, if it cannot. */ +export function schemeProblem(scheme: string): string | undefined { + if (scheme.length === 0 || scheme.length > 64) return "a redirect scheme is 1–64 characters"; + const first = scheme.charCodeAt(0); + if (first < 97 || first > 122) return "a redirect scheme starts with a letter"; + for (const c of scheme) + if (!((c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "+" || c === "-" || c === ".")) + return "a redirect scheme has letters, digits, + - . only"; + if (DANGEROUS_SCHEMES.has(scheme)) return `${scheme}: is never a redirect target`; + // `http`, `https` and lookalikes (`https-app`, `httpx`), and WebSocket schemes. + if (scheme.startsWith("http") || scheme === "ws" || scheme === "wss") + return `${scheme}: looks like a web scheme`; + return undefined; +} + +export interface RedirectPolicy { + /** `CLOUDHAL_MCP_CLIENT_HOSTS`; empty or absent: any host. */ + readonly clientHosts?: readonly string[] | undefined; + /** `CLOUDHAL_MCP_REDIRECT_SCHEMES`, added to `KNOWN_REDIRECT_SCHEMES`. */ + readonly schemes?: readonly string[] | undefined; +} + +/** The problem with a private-use redirect (`scheme` lower case), or `undefined`. */ +function privateUseProblem( uri: string, - clientHosts?: readonly string[] | undefined, + scheme: string, + policy: RedirectPolicy, ): string | undefined { + const problem = schemeProblem(scheme); + if (problem) return problem; + if (uri.slice(scheme.length, scheme.length + 2) !== ":/") + return `a private-use redirect URI is ${scheme}:/… (RFC 8252 §7.1)`; + const listed = [...KNOWN_REDIRECT_SCHEMES, ...(policy.schemes ?? [])].includes(scheme); + const restricted = (policy.clientHosts?.length ?? 0) > 0; + if (listed) return undefined; + if (restricted) + return `${scheme}: is not a known app scheme, and CLOUDHAL_MCP_CLIENT_HOSTS is set (add it to CLOUDHAL_MCP_REDIRECT_SCHEMES)`; + if (!scheme.includes(".")) + return `${scheme}: is not reverse-domain (com.example.app) or a known app scheme (CLOUDHAL_MCP_REDIRECT_SCHEMES)`; + return undefined; +} + +/** Why a redirect URI is refused, or `undefined` when it may be used. */ +export function redirectProblem(uri: string, policy: RedirectPolicy = {}): string | undefined { if (uri.length > MAX_URI_LENGTH) return "a redirect URI is longer than 2,048 characters"; let url: URL; try { @@ -61,32 +129,36 @@ export function redirectProblem( } if (uri.includes("#")) return "a redirect URI must not have a fragment"; if (url.username || url.password) return "a redirect URI must not carry credentials"; - const loopback = url.protocol === "http:" && LOOPBACK_HOSTS.has(rawHttpHost(uri) ?? ""); - if (url.protocol === "http:" && !loopback) + const scheme = url.protocol.slice(0, -1).toLowerCase(); + if (scheme !== "http" && scheme !== "https") return privateUseProblem(uri, scheme, policy); + const loopback = scheme === "http" && LOOPBACK_HOSTS.has(rawHttpHost(uri) ?? ""); + if (scheme === "http" && !loopback) return `http redirect URIs are allowed on localhost, 127.0.0.1 and [::1] only: ${url.host}`; - if (url.protocol === "https:" && LOOPBACK_HOSTS.has(url.hostname)) + if (scheme === "https" && LOOPBACK_HOSTS.has(url.hostname)) return "a loopback redirect URI uses http (RFC 8252 §7.3)"; - if (url.protocol !== "http:" && url.protocol !== "https:") - return `redirect URIs use https (or http on loopback), not ${url.protocol}`; - if (clientHosts && clientHosts.length > 0) { + const hosts = policy.clientHosts; + if (hosts && hosts.length > 0) { const host = loopback ? "localhost" : url.hostname; - if (!clientHosts.some((p) => hostMatches(host, p))) + if (!hosts.some((p) => hostMatches(host, p))) return `${host} is not on this server's list of client hosts (CLOUDHAL_MCP_CLIENT_HOSTS)`; } return undefined; } -/** Why a client's redirect URIs are refused as a whole (1–10, each allowed), or `undefined`. */ +/** + * Why a client's redirect URIs are refused as a whole (1–10, each allowed), or `undefined`. One + * bad entry refuses the registration: entries are never dropped silently. + */ export function redirectUrisProblem( uris: unknown, - clientHosts?: readonly string[] | undefined, + policy: RedirectPolicy = {}, ): string | undefined { if (!Array.isArray(uris) || uris.length === 0) return "redirect_uris is required"; if (uris.length > MAX_REDIRECT_URIS) return `at most ${MAX_REDIRECT_URIS} redirect URIs may be registered`; for (const uri of uris) { if (typeof uri !== "string") return "redirect_uris must be strings"; - const problem = redirectProblem(uri, clientHosts); + const problem = redirectProblem(uri, policy); if (problem) return problem; } return undefined; diff --git a/packages/api/src/oauth-prune-job.ts b/packages/api/src/oauth-prune-job.ts index d160046d..6c7a1aea 100644 --- a/packages/api/src/oauth-prune-job.ts +++ b/packages/api/src/oauth-prune-job.ts @@ -1,12 +1,14 @@ /** - * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes dynamically registered - * OAuth clients nobody used for 30 days (`pruneDcrClients`), on the admin Worker's tick and on - * Node's (`cloudhal studio`, `dev`, `mcp`). It runs whether DCR is on or off: clients registered - * while it was on still age out. Bounded: at most `PRUNE_BATCH` clients per run, one batch. + * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes unused OAuth clients + * (`pruneDcrClients`, `pruneCimdClients`): dynamically registered ones nobody used for 30 days, and clients from + * metadata documents nobody connected through or fetched for a day (the next authorization + * stores them again). On the admin Worker's tick and on Node's (`cloudhal studio`, `dev`, + * `mcp`), whether DCR and CIMD are on or off. Bounded: one kind per run (alternate hours), at most + * `PRUNE_BATCH` clients, one batch. */ import { hourlyAt, type SystemJobDefinition } from "@cloudhal/schedules"; import type { SqlDriver } from "@cloudhal/storage"; -import { pruneDcrClients } from "./auth/oauth-registration.ts"; +import { pruneCimdClients, pruneDcrClients } from "./auth/oauth-client-rows.ts"; export function oauthClientsPruneJob( sql: SqlDriver, @@ -14,12 +16,21 @@ export function oauthClientsPruneJob( ): SystemJobDefinition { return { name: "oauth.clients.prune", - // One batch of six statements. + // One batch of six statements: one kind per run, so the job fits next to the other hourly + // jobs in a free plan's tick (DCR clients in even UTC hours, document clients in odd ones). cost: 6, - next: hourlyAt(41), + // The hourly jobs' :17 watermark: due jobs run by time, then name, so this one runs after + // the analytics rollup and the comments job and waits for the next minute when a free + // plan's tick is full. + next: hourlyAt(17), async run({ now }) { - const deleted = await pruneDcrClients(sql, { now }); - if (deleted > 0) log(`oauth clients: ${deleted} unused registration(s) deleted`); + if (now.getUTCHours() % 2 === 0) { + const n = await pruneDcrClients(sql, { now }); + if (n > 0) log(`oauth clients: ${n} unused registration(s) deleted`); + } else { + const n = await pruneCimdClients(sql, { now }); + if (n > 0) log(`oauth clients: ${n} unused document client(s) deleted`); + } }, }; } diff --git a/packages/api/src/testing-mcp-oauth-bounds.ts b/packages/api/src/testing-mcp-oauth-bounds.ts new file mode 100644 index 00000000..4595791c --- /dev/null +++ b/packages/api/src/testing-mcp-oauth-bounds.ts @@ -0,0 +1,312 @@ +/** + * Shared suite for the review of RFC-0044 Phase 3a (PR #178): the bounds around client + * registration, on better-sqlite3 and Miniflare D1. Next to `testing-mcp-oauth-registration.ts`. + * + * 1. Document clients are capped and pruned like DCR's. + * 2. Anonymous calls cannot spend the fetch budgets members' clients need: a stored, fresh client + * is never fetched; a stored one is never blocked by a budget (stale-if-exhausted); only new + * documents spend the per-origin budget. A second deployment over the same database stands in + * for a Worker, which rebuilds the plugin (and its cache) on every request. + * 3. Cursor registers with its real body, a private-use scheme included; dangerous schemes never. + * 4. The client host list applies at every authorization, to clients registered before it. + * 5. With DCR off, clients it registered earlier start no new authorization; connections they + * already have keep working. + * 6. The consent screen's identity: names made plain and short, the kind, look-alike names. + */ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { pruneCimdClients } from "./auth/oauth-client-rows.ts"; +import type { OauthRegistration } from "./auth/oauth-registration.ts"; +import { + DOC, + document, + FETCH_FAILED, + stubTransport, + tryAuthorize, + withCimd, +} from "./testing-mcp-oauth-registration.ts"; +import { + authorize, + connect, + consentRequest, + deploy, + ISSUER, + type Json, + mcpClient, + REDIRECT, + registerClient, + type Server, + setupServer, + token, +} from "./testing-oauth-harness.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +const READ = "site:read content:read"; +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +/** Cursor 3.14.27's registration as observed (forum.cursor.com/t/167608). */ +const CURSOR_BODY = { + client_name: "Cursor", + redirect_uris: [ + "cursor://anysphere.cursor-mcp/oauth/callback", + "https://www.cursor.com/agents/mcp/oauth/callback", + "http://localhost:8787/callback", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", +}; + +/** The same database and owner, served by another deployment (a Worker's next request). */ +async function redeploy(s: Server, options: { registration: OauthRegistration }): Promise { + return { ...s, ...(await deploy(s.backend, options)) }; +} + +/** An anonymous `/authorize` for `clientId` from `address`; the response status and text. */ +async function anonymousAuthorize(s: Server, clientId: string, address: string) { + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + scope: READ, + code_challenge: "x".repeat(43), + code_challenge_method: "S256", + }); + const res = await s.app.request(`${ISSUER}/oauth2/authorize?${params}`, { + headers: { "cf-connecting-ip": address }, + }); + return { status: res.status, text: await res.text() }; +} + +/** Authorize went on: the consent screen, or (consent remembered, Decision 8) a code. */ +const proceeds = (r: Awaited>) => + r.consentScreen || Boolean(r.location?.searchParams.get("code")); + +const ageRow = (s: Server, clientId: string, ms: number) => + s.sql.run(`UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" = ?`, [ + new Date(Date.now() - ms).toISOString(), + clientId, + ]); + +export function describeMcpOauthBounds(label: string, backend: RolesBackend): void { + describe(`MCP OAuth registration bounds: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + afterEach(() => { + vi.useRealTimers(); + clearAccessTokenKeyCache(); + }); + + it("caps stored document clients and prunes unused ones (the next authorize recreates them)", async () => { + const other = "https://agents.example.com/other.json"; + const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) })); + const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 1 } })); + const { access } = await connect( + { ...s, clientId: DOC }, + { cookie: s.owner.cookie, scope: READ }, + ); + const full = await tryAuthorize(s, other); + expect(full.status).toBe(503); + expect(full.reason).toContain("stores no more clients"); + // A live connection keeps its client however idle; an unused one goes after a day. + await registerClient(s); // a DCR client is not a document client: untouched here + const stored = async () => + ( + await s.sql.all<{ clientId: string }>( + `SELECT "clientId" FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`, + ) + ).map((r) => r.clientId); + expect(await pruneCimdClients(s.sql, { now: new Date(Date.now() + 2 * DAY) })).toBe(0); + expect(await stored()).toEqual([DOC]); + const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + await s.call("DELETE", `/api/agent-grants/${grants.find((g) => g.clientId === DOC)?.id}`, { + cookie: s.owner.cookie, + headers: { origin: s.origin }, + }); + expect(await pruneCimdClients(s.sql, { now: new Date(Date.now() + 2 * DAY) })).toBe(1); + expect(await stored()).toEqual([]); + void access; + // Room again: another document can connect. + expect((await tryAuthorize(s, other)).consentScreen).toBe(true); + }); + + it("keeps stored clients connecting and refreshing while anonymous calls drain the budgets", async () => { + const transport = stubTransport((url) => + url.toString() === DOC ? { body: document() } : { status: 404 }, + ); + const registration = withCimd(transport, { + cimdLimits: { perAddressPerMinute: 4, perOriginPerMinute: 3, perMinute: 5 }, + }); + const s = await setupServer(backend, registration); + const claude = { ...s, clientId: DOC }; + const first = await connect(claude, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + const fetches = () => transport.requests.filter((u) => u === DOC).length; + expect(fetches()).toBe(1); + // Anonymous callers from many addresses exhaust the per-origin and the total budget. + const answers: number[] = []; + for (let i = 0; i < 8; i++) + answers.push( + (await anonymousAuthorize(s, `https://f${i}.example.com/c.json`, `203.0.113.${i}`)) + .status, + ); + expect(answers.at(-1)).toBe(429); + // A new request on another deployment (a Worker rebuilds the plugin): the stored, fresh + // client is used from its row, with no fetch, for authorize and for a refresh. + const w = await redeploy(s, registration); + expect(proceeds(await tryAuthorize(w, DOC))).toBe(true); + const refreshed = await token( + { ...w, clientId: DOC }, + { grant_type: "refresh_token", refresh_token: first.refresh ?? "" }, + ); + expect(refreshed.status, JSON.stringify(refreshed.body)).toBe(200); + expect(fetches()).toBe(1); + // Older than the freshness window: it is fetched again, and only the caller's budget counts. + await ageRow(s, DOC, 2 * HOUR); + expect(proceeds(await tryAuthorize(w, DOC))).toBe(true); + expect(fetches()).toBe(2); + // The caller's own budget spent too: the stored client is served from its row. + await ageRow(s, DOC, 2 * HOUR); + for (let i = 0; i < 4; i++) + await anonymousAuthorize(w, `https://g${i}.example.com/c.json`, "unknown"); + const stale = await token( + { ...w, clientId: DOC }, + { grant_type: "refresh_token", refresh_token: refreshed.body.refresh_token as string }, + ); + expect(stale.status, JSON.stringify(stale.body)).toBe(200); + expect(fetches()).toBe(2); + expect(proceeds(await tryAuthorize(w, DOC))).toBe(true); + await (await mcpClient(w.app, stale.body.access_token as string)).listTools(); + // A new document from that caller is refused while its budget is spent. + const blocked = await tryAuthorize(w, "https://agents.example.com/new.json"); + expect(blocked.status).toBe(429); + }); + + it("registers Cursor with its real body, and refuses dangerous or unknown schemes", async () => { + const s = await setupServer(backend, { + registration: { dcr: true, limits: { perAddressPerHour: 100 } }, + }); + const cursor = await s.call("POST", "/api/auth/oauth2/register", { body: CURSOR_BODY }); + expect(cursor.status, JSON.stringify(cursor.body)).toBe(201); + expect(cursor.body.redirect_uris).toEqual(CURSOR_BODY.redirect_uris); + const id = cursor.body.client_id as string; + // Private-use redirects match exactly: no port or path relaxation. + expect( + (await tryAuthorize(s, id, CURSOR_BODY.redirect_uris[0] as string)).consentScreen, + ).toBe(true); + expect( + (await tryAuthorize(s, id, "cursor://anysphere.cursor-mcp/oauth/other")).consentScreen, + ).toBe(false); + expect((await tryAuthorize(s, id, "http://localhost:9999/callback")).consentScreen).toBe( + true, + ); + const register = (uri: string) => + s.call("POST", "/api/auth/oauth2/register", { + body: { ...CURSOR_BODY, redirect_uris: [uri] }, + }); + for (const uri of [ + "javascript://x/%0aalert(1)", + "data:/text/html,x", + "file:///etc/passwd", + "vbscript://x/y", + "about:/blank", + "blob:/x", + "httpx://evil.example/cb", + "https-app://cb/x", + "cursor:callback", + "myapp://app/callback", + ]) { + const r = await register(uri); + expect(r.status, uri).toBe(400); + expect(r.body.error, uri).toBe("invalid_redirect_uri"); + } + expect((await register("com.example.app:/oauth/callback")).status).toBe(201); + expect( + (await register("vscode://vscode.github-authentication/did-authenticate")).status, + ).toBe(201); + // An operator's own scheme, and the host list's narrower rule. + const t = await setupServer(backend, { + registration: { dcr: true, redirectSchemes: ["myapp"], clientHosts: ["localhost"] }, + }); + const to = (uri: string) => + t.call("POST", "/api/auth/oauth2/register", { + body: { ...CURSOR_BODY, redirect_uris: [uri] }, + }); + expect((await to("myapp://app/callback")).status).toBe(201); + expect((await to("cursor://anysphere.cursor-mcp/oauth/callback")).status).toBe(201); + expect((await to("com.example.app:/oauth/callback")).status).toBe(400); + }); + + it("applies the client host list at every authorization, to clients registered before it", async () => { + const s = await setupServer(backend); + const evil = "https://cursor-login.evil.example/cb"; + const client = await registerClient(s, { client_name: "Cursor", redirect_uris: [evil] }); + expect((await tryAuthorize(s, client, evil)).consentScreen).toBe(true); + const listed = await redeploy(s, { + registration: { dcr: true, clientHosts: ["cursor.com", "localhost"] }, + }); + const r = await tryAuthorize(listed, client, evil); + expect(r.consentScreen).toBe(false); + expect(r.reason).toBe("invalid redirect uri"); + // A loopback redirect still passes with `localhost` listed. + expect((await tryAuthorize(listed, s.clientId)).consentScreen).toBe(true); + }); + + it("refuses new authorizations of DCR clients once DCR is off; their connections keep working", async () => { + const s = await setupServer(backend); + const { access, refresh } = await connect(s, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + const off = await redeploy(s, { registration: { dcr: false } }); + const r = await tryAuthorize(off, s.clientId); + expect(r.consentScreen).toBe(false); + expect(r.reason).toContain("dynamic client registration is off"); + await (await mcpClient(off.app, access)).listTools(); + const renewed = await token(off, { + grant_type: "refresh_token", + refresh_token: refresh ?? "", + }); + expect(renewed.status, JSON.stringify(renewed.body)).toBe(200); + }); + + it("shows a client's name plain and short, its kind, and flags look-alike self-registrations", async () => { + const name = `Claude‮ edoC​ ${" ".repeat(400)}from claude.ai · sends you back to claude.ai ${"x".repeat(300)}`; + const transport = stubTransport(() => ({ body: document({ client_name: name }) })); + const s = await setupServer(backend, withCimd(transport)); + const info = async (clientId: string, redirectUri = REDIRECT) => { + const start = await authorize(s, { + cookie: s.owner.cookie, + scope: READ, + clientId, + redirectUri, + }); + const request = consentRequest(start.location as URL); + return ( + await s.call("GET", `/api/agent-grants/consent?request=${encodeURIComponent(request)}`, { + cookie: s.owner.cookie, + }) + ).body.client as Json; + }; + const doc = await info(DOC); + expect(doc).toMatchObject({ kind: "document", host: "agents.example.com" }); + expect([...(doc.name as string)].length).toBeLessThanOrEqual(100); + expect(doc.name).not.toMatch(/[‮​ ]/); + expect(doc.name).toMatch(/^Claude edoC from claude\.ai/); + expect(doc.lookalike).toBeUndefined(); + const fake = await registerClient(s, { client_name: "Claude · Identified by claude.ai" }); + expect(await info(fake)).toMatchObject({ kind: "self-registered", lookalike: "Claude" }); + expect((await info(s.clientId)).lookalike).toBeUndefined(); // "Test Agent" + // The connection keeps the shown name. + await connect({ ...s, clientId: DOC }, { cookie: s.owner.cookie, scope: READ }); + const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) + .body as unknown as Json[]; + expect(grants.find((g) => g.clientId === DOC)?.clientName).toBe(doc.name); + void FETCH_FAILED; + }); + }); +} diff --git a/packages/api/src/testing-mcp-oauth-registration.ts b/packages/api/src/testing-mcp-oauth-registration.ts index f420c4c3..38ac8b95 100644 --- a/packages/api/src/testing-mcp-oauth-registration.ts +++ b/packages/api/src/testing-mcp-oauth-registration.ts @@ -15,7 +15,8 @@ import type { FetchTransport } from "@cloudhal/core-ts/media"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; import { cimdFetch } from "./auth/cimd-fetch.ts"; -import { type OauthRegistration, pruneDcrClients } from "./auth/oauth-registration.ts"; +import { pruneDcrClients } from "./auth/oauth-client-rows.ts"; +import type { OauthRegistration } from "./auth/oauth-registration.ts"; import { redirectProblem } from "./auth/redirect-policy.ts"; import { oauthClientsPruneJob } from "./oauth-prune-job.ts"; import { @@ -34,7 +35,7 @@ import type { RolesBackend } from "./testing-roles.ts"; const READ = "site:read content:read"; const DAY = 86_400_000; /** A client metadata document URL (RFC-0044 §3: HTTPS with a path). */ -const DOC = "https://agents.example.com/oauth/client.json"; +export const DOC = "https://agents.example.com/oauth/client.json"; /** A public address the stub resolver answers (TEST-NET-3 is not public: use a real range). */ const PUBLIC = "93.184.215.14"; @@ -47,7 +48,7 @@ interface Served { } /** A transport that serves documents from the test and records every request. */ -function stubTransport( +export function stubTransport( serve: (url: URL) => Served, addresses: readonly string[] = [PUBLIC], ): FetchTransport & { readonly requests: string[] } { @@ -73,7 +74,7 @@ function stubTransport( }; } -function document(overrides: Json = {}): string { +export function document(overrides: Json = {}): string { return JSON.stringify({ client_id: DOC, client_name: "Claude Code", @@ -86,7 +87,7 @@ function document(overrides: Json = {}): string { } /** CIMD (and DCR, unless turned off) over `transport`, with a short timeout. */ -function withCimd(transport: FetchTransport, extra: Partial = {}) { +export function withCimd(transport: FetchTransport, extra: Partial = {}) { return { registration: { cimd: cimdFetch({ transport, blockedHosts: ["localhost"], timeoutMs: 200 }), @@ -97,7 +98,7 @@ function withCimd(transport: FetchTransport, extra: Partial = } /** `authorize` as the member's browser for another client: the consent screen, or the refusal. */ -async function tryAuthorize(s: Server, clientId: string, redirectUri = REDIRECT) { +export async function tryAuthorize(s: Server, clientId: string, redirectUri = REDIRECT) { const r = await authorize(s, { cookie: s.owner.cookie, scope: READ, clientId, redirectUri }); const consentScreen = r.location?.pathname === "/oauth/consent"; const text = consentScreen ? "" : await r.res.text(); @@ -106,7 +107,7 @@ async function tryAuthorize(s: Server, clientId: string, redirectUri = REDIRECT) return { ...r, consentScreen, status: r.res.status, text, reason }; } -const FETCH_FAILED = /Failed to fetch metadata document/; +export const FETCH_FAILED = /Failed to fetch metadata document/; export function describeMcpOauthRegistration(label: string, backend: RolesBackend): void { describe(`MCP OAuth client registration: ${label}`, { timeout: 180_000 }, () => { @@ -212,17 +213,15 @@ export function describeMcpOauthRegistration(label: string, backend: RolesBacken expect((await tryAuthorize(t, DOC)).consentScreen).toBe(true); }); - it("uses a document's redirect URIs only as https or loopback http", async () => { + it("never uses a document's redirect URI that the redirect rules refuse", async () => { const transport = stubTransport(() => ({ body: document({ - redirect_uris: ["http://127.0.0.1/callback", "cursor://anysphere.cursor/callback"], + redirect_uris: ["http://127.0.0.1/callback", "myapp://app/callback"], }), })); const s = await setupServer(backend, withCimd(transport)); expect((await tryAuthorize(s, DOC)).consentScreen).toBe(true); - expect( - (await tryAuthorize(s, DOC, "cursor://anysphere.cursor/callback")).consentScreen, - ).toBe(false); + expect((await tryAuthorize(s, DOC, "myapp://app/callback")).consentScreen).toBe(false); }); }); @@ -252,14 +251,15 @@ export function describeMcpOauthRegistration(label: string, backend: RolesBacken await (await mcpClient(s.app, access)).listTools(); }); - it("takes public clients with https or loopback redirect URIs only", async () => { + it("takes public clients with https, loopback or known app redirect URIs only", async () => { const s = await setupServer(backend, { registration: { dcr: true, limits: { perAddressPerHour: 100 } }, }); const refused: [Json, string][] = [ [{ token_endpoint_auth_method: "client_secret_basic" }, "invalid_client_metadata"], [{ redirect_uris: ["http://cursor.example.com/callback"] }, "invalid_redirect_uri"], - [{ redirect_uris: ["cursor://anysphere.cursor/callback"] }, "invalid_redirect_uri"], + [{ redirect_uris: ["myapp://app/callback"] }, "invalid_redirect_uri"], + [{ redirect_uris: [REDIRECT, "javascript://x/%0aalert(1)"] }, "invalid_redirect_uri"], [{ redirect_uris: ["https://localhost/callback"] }, "invalid_redirect_uri"], [{ redirect_uris: ["https://app.example.com/cb#x"] }, "invalid_redirect_uri"], [{ redirect_uris: [] }, "invalid_redirect_uri"], @@ -352,8 +352,11 @@ export function describeMcpOauthRegistration(label: string, backend: RolesBacken expect(await pruneDcrClients(s.sql, { now: new Date(Date.now() + 29 * DAY) })).toBe(0); // The scheduler's hourly job does it (Workers and Node), and says so. const lines: string[] = []; + // DCR clients go in even UTC hours (document clients in odd ones). + const later = new Date(Date.now() + 31 * DAY); + later.setUTCHours(later.getUTCHours() + (later.getUTCHours() % 2)); await oauthClientsPruneJob(s.sql, (l) => lines.push(l)).run({ - now: new Date(Date.now() + 31 * DAY), + now: later, trigger: "cron", }); expect(lines).toEqual(["oauth clients: 2 unused registration(s) deleted"]); @@ -422,10 +425,18 @@ export function describeMcpOauthRegistration(label: string, backend: RolesBacken expect(redirectProblem("http://user@localhost/cb")).toBeDefined(); expect(redirectProblem(`https://a.com/${"a".repeat(100_000)}`)).toBeDefined(); expect(redirectProblem("http://".padEnd(100_000, "["))).toBeDefined(); - expect(redirectProblem("https://x.cursor.com/cb", ["*.cursor.com"])).toBeUndefined(); - expect(redirectProblem("https://cursor.com/cb", ["*.cursor.com"])).toBeDefined(); - expect(redirectProblem("http://localhost:9/cb", ["claude.ai"])).toBeDefined(); - expect(redirectProblem("http://localhost:9/cb", ["localhost"])).toBeUndefined(); + expect( + redirectProblem("https://x.cursor.com/cb", { clientHosts: ["*.cursor.com"] }), + ).toBeUndefined(); + expect( + redirectProblem("https://cursor.com/cb", { clientHosts: ["*.cursor.com"] }), + ).toBeDefined(); + expect( + redirectProblem("http://localhost:9/cb", { clientHosts: ["claude.ai"] }), + ).toBeDefined(); + expect( + redirectProblem("http://localhost:9/cb", { clientHosts: ["localhost"] }), + ).toBeUndefined(); }); }); diff --git a/packages/api/test/mcp-oauth-registration.test.ts b/packages/api/test/mcp-oauth-registration.test.ts index c1cbcac5..ab75ad29 100644 --- a/packages/api/test/mcp-oauth-registration.test.ts +++ b/packages/api/test/mcp-oauth-registration.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 { describeMcpOauthBounds } from "../src/testing-mcp-oauth-bounds.ts"; import { describeMcpOauthRegistration } from "../src/testing-mcp-oauth-registration.ts"; -describeMcpOauthRegistration("better-sqlite3", { +const sqlite = { async create() { const database = new Database(":memory:"); const sql = new BetterSqlite3Driver(database); @@ -15,11 +16,13 @@ describeMcpOauthRegistration("better-sqlite3", { await store.init(); return { store, sql, blobs, database }; }, -}); +}; +describeMcpOauthRegistration("better-sqlite3", sqlite); +describeMcpOauthBounds("better-sqlite3", sqlite); let d1: Awaited> | undefined; const reset = createTableReset(); -describeMcpOauthRegistration("Cloudflare D1 (Miniflare)", { +const miniflare = { async create() { d1 ??= await createMiniflareD1(); const sql = new D1Driver(d1.db); @@ -28,6 +31,11 @@ describeMcpOauthRegistration("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. +describeMcpOauthRegistration("Cloudflare D1 (Miniflare)", miniflare); +describeMcpOauthBounds("Cloudflare D1 (Miniflare)", { + ...miniflare, async dispose() { await d1?.dispose(); }, diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts index fdafabcf..a28f8cb3 100644 --- a/packages/cloudflare/src/admin-worker.ts +++ b/packages/cloudflare/src/admin-worker.ts @@ -119,6 +119,8 @@ export interface AdminEnv extends CommentsEnv, SchedulerEnv, MailEnv, BuildRooms CLOUDHAL_MCP_DCR?: string; /** Comma-separated hosts OAuth clients may come from (`claude.ai,*.example.com`); unset: any. */ CLOUDHAL_MCP_CLIENT_HOSTS?: string; + /** Extra private-use redirect schemes of native clients (besides `cursor`, `vscode`). */ + CLOUDHAL_MCP_REDIRECT_SCHEMES?: string; } /** From 6a459595797a5926ebbb2a5b6281542a95b7613e Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:06:58 +0300 Subject: [PATCH 5/7] fix(api): notify an on-approval release only while its request is pending (#144) Review round 2 of #178, item 7. approvalDue now reads the release's linked approval request: an on-approval release awaiting approval is due only while that request is pending and unexpired. After the request's 24 h TTL nobody can approve it, so nobody keeps a notification for it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- packages/api/src/routes/notifications.ts | 18 +++++++++++----- packages/api/src/testing-schedules.ts | 26 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts index 93573be7..599586c1 100644 --- a/packages/api/src/routes/notifications.ts +++ b/packages/api/src/routes/notifications.ts @@ -329,11 +329,19 @@ const DAY = 24 * 60 * MINUTE; /** * When a release awaiting approval is due for the people who may decide it: within 60 minutes of - * its time, or, for an `on-approval` release (an OAuth connection's embargo, RFC-0044), for as - * long as its request waits. + * its time; or, for an `on-approval` release (an OAuth connection's embargo, RFC-0044), while its + * request is pending and has not expired (after that nobody can approve it, only cancel it). */ -function approvalDue(r: { when?: { kind: string } | undefined }, due: number, t: number): boolean { - return r.when?.kind === "on-approval" || (due - t <= 60 * MINUTE && due >= t); +async function approvalDue( + c: Context, + r: { siteId: string; id: string; when?: { kind: string } | undefined }, + times: { due: number; t: number }, +): Promise { + const { due, t } = times; + if (r.when?.kind !== "on-approval") return due - t <= 60 * MINUTE && due >= t; + const rec = await c.get("schedules")?.store.getReleaseRecord(r.siteId, r.id); + const request = rec?.requestId ? await c.get("approvals")?.get(rec.requestId) : undefined; + return request?.status === "pending" && Date.parse(request.expiresAt) > t; } /** The schedule kinds (RFC-0008 §14), computed from state; nothing is stored. */ @@ -375,7 +383,7 @@ async function scheduleNotifications(c: Context, now: Date): Promise { + const live = (await store.listContentEntries(site.id))[0] as ContentEntry; + const setEmbargo = defineTools().find((t) => t.name === "set_embargo"); + if (!setEmbargo) throw new Error("set_embargo"); + const editor = person("editor"); + const principal: Principal = { + actor: agent, + scopes: ["schedules:write", "content:write", "content:read", "site:read"], + mode: "edits", + grantId: "grant_desk00000002", + ...(editor.person ? { person: editor.person } : {}), + }; + const result = (await setEmbargo.handler( + { siteId: site.id, nodeId: live.id, from: plus(30 * HOUR), expectedRev: live.rev }, + { store, principal, schedules: deps, approvals, now: () => clock.now }, + )) as { status: string; approvalId: string }; + expect(result.status).toBe("approval-pending"); + const kinds = async () => + ((await call("/api/notifications", "editor")).body.items as Json[]).map((n) => n.kind); + clock.now = new Date(NOW.getTime() + 23 * HOUR); + expect(await kinds()).toContain("schedule-approval-due"); + // The request lives 24 hours; after that nobody can approve it, so nobody is told to. + clock.now = new Date(NOW.getTime() + 25 * HOUR); + expect(await kinds()).not.toContain("schedule-approval-due"); + }); + it("automations: built-ins and agent tasks with their key; pause, resume, delete; preview", async () => { const builtin = await post(`${S()}/automations`, { name: "Links", From 06cd53f1592d4af71df05cbc2a8a7ccc85cf2897 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:06:58 +0300 Subject: [PATCH 6/7] fix(api): close the CIMD budget and cap lockouts; consent identity round 2 (#144) Review round 2 of #178. 1. The plugin's own fetch windows are set out of reach and its cache is off, so the stored row is the only cache and Cloudhal's rate_limits budgets the only budgets, on Node's long-lived instance too. A declined plugin fetch serves a stored row younger than 24 h. 2. At the cap a new document evicts the oldest grantless client not fetched for 10 min (else 503). The host list and the well-known document hosts (claude.ai, claude.com, chatgpt.com, openai.com, vscode.dev, github.com) are exempt from the cap and from eviction. Grantless document clients are pruned after 3 h, in up to four batches of 50 per run; DCR and CIMD are pruned every run. 3. The caller's own budget comes first and answers 429, never a stale row. A failed refetch is recorded on the row and refuses the client until a fetch succeeds. 4. Migration 0032_oauth_client_kind_index on oauthClient (clientDiscoveryId, updatedAt). The cap count runs only for a new document, after the per-address hit. 5. CIMD-off keeps cutting existing document connections (conservative); documented next to DCR-off. 6. Consent screen: a private-use redirect reads as an app on this computer; the title puts the host first; at most two combining marks per character; look-alikes are compared after NFKC plus a small confusable map, and flagged only for the CIMD well-known names, and not when the client is a document on that client's real host. New strings in en; the other languages are machine drafts. 8. Test: application_type web with a private-use redirect is refused. Each fix has a test that fails on be319b8f, except 8, which guards a provider rule. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/mcp-oauth-client-registration.md | 3 +- apps/studio/src/i18n/generated/params.ts | 3 +- apps/studio/src/i18n/lazy/oauthConsent.ar.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.de.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.en.ts | 5 +- apps/studio/src/i18n/lazy/oauthConsent.es.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.fa.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.fr.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.he.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.hi.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.id.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.it.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.ja.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.ko.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.nl.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.pl.ts | 7 +- .../src/i18n/lazy/oauthConsent.pt-BR.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.ru.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.tr.ts | 7 +- apps/studio/src/i18n/lazy/oauthConsent.uk.ts | 7 +- .../src/i18n/lazy/oauthConsent.zh-Hans.ts | 7 +- apps/studio/src/lib/api/agent-grants.ts | 5 +- apps/studio/src/routes/OAuthConsentPage.tsx | 89 ++++--- apps/studio/test/oauth-consent.test.tsx | 23 +- docs/adr/0045-mcp-authorization-server.md | 6 +- docs/guides/cloudflare-hardening.md | 13 +- packages/api/src/auth/agent-consent.ts | 20 +- packages/api/src/auth/cimd-discovery.ts | 148 ++++++++---- packages/api/src/auth/cimd-guard.ts | 28 +++ packages/api/src/auth/client-identity.ts | 143 +++++++++-- packages/api/src/auth/instance.ts | 13 +- packages/api/src/auth/oauth-client-rows.ts | 113 +++++++-- packages/api/src/auth/oauth-registration.ts | 25 +- packages/api/src/oauth-prune-job.ts | 32 ++- packages/api/src/testing-mcp-oauth-bounds.ts | 222 ++++++++++++++++-- .../api/src/testing-mcp-oauth-registration.ts | 11 +- .../0032_oauth_client_kind_index.ts | 15 ++ packages/storage/migrations/index.ts | 2 + packages/storage/test/migrate.test.ts | 19 ++ 39 files changed, 808 insertions(+), 256 deletions(-) create mode 100644 packages/api/src/auth/cimd-guard.ts create mode 100644 packages/storage/migrations/0032_oauth_client_kind_index.ts diff --git a/.changeset/mcp-oauth-client-registration.md b/.changeset/mcp-oauth-client-registration.md index f6386d9d..8b502219 100644 --- a/.changeset/mcp-oauth-client-registration.md +++ b/.changeset/mcp-oauth-client-registration.md @@ -1,4 +1,5 @@ --- +"@cloudhal/storage": patch "@cloudhal/api": patch "@cloudhal/cloudflare": patch "@cloudhal/cli": patch @@ -6,4 +7,4 @@ "@cloudhal/core-ts": patch --- -Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Document clients are fetched at most hourly per client, with budgets that never lock out stored clients, a cap of 5,000 and pruning after a day unused. Redirect URIs must be https, loopback http (any port) or a native app's private-use scheme (`cursor`, `vscode`, reverse-domain, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), and the client host list applies at every authorization. With DCR off, the clients it registered earlier cannot start new authorizations. The consent screen shows client names plain and short, a document client's host first, and marks self-registered clients as unverified. `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off`, `CLOUDHAL_MCP_CLIENT_HOSTS` and `CLOUDHAL_MCP_REDIRECT_SCHEMES` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied, and the request is notified while it waits. `safeFetch` results carry the response headers. +Remote MCP clients can now register themselves (RFC-0044 Phase 3a, ADR-0045). Clients that name an HTTPS Client ID Metadata Document as their `client_id` (Claude Code, Codex, VS Code) are accepted through `@better-auth/cimd`, fetched with the media import's SSRF-safe transport (pinned on Node, DoH-checked on Workers; https only, public addresses only, no redirects, 5 KB, 5 s). Clients without one (Cursor) use dynamic client registration at `/api/auth/oauth2/register`: public clients only, rate-limited per address and per day, capped at 5,000, and pruned by the scheduler after 30 unused days. Document clients are fetched at most hourly per client, with budgets in Cloudhal's own counters that never lock out stored clients, a cap of 5,000 that evicts unused clients (the well-known clients' hosts are exempt), pruning after three hours unused, and a failed refetch that refuses the client; migration `0032_oauth_client_kind_index` indexes them. Redirect URIs must be https, loopback http (any port) or a native app's private-use scheme (`cursor`, `vscode`, reverse-domain, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), and the client host list applies at every authorization. With DCR off, the clients it registered earlier cannot start new authorizations. The consent screen shows client names plain and short, a document client's host first, and marks self-registered clients as unverified. `CLOUDHAL_MCP_CIMD=off`, `CLOUDHAL_MCP_DCR=off`, `CLOUDHAL_MCP_CLIENT_HOSTS` and `CLOUDHAL_MCP_REDIRECT_SCHEMES` control it on both runtimes, and the Docker compose file passes them through. The Phase 2 server-side test client (`OauthServer.createClient`) is removed. `set_embargo` through an OAuth connection now files the gate for a person's approval instead of leaving it unapplied, and the request is notified while it waits. `safeFetch` results carry the response headers. diff --git a/apps/studio/src/i18n/generated/params.ts b/apps/studio/src/i18n/generated/params.ts index 5557b9fe..0b755231 100644 --- a/apps/studio/src/i18n/generated/params.ts +++ b/apps/studio/src/i18n/generated/params.ts @@ -550,10 +550,11 @@ export interface MessageParams { "oauthConsent.scopes.notInRole": { label: string | number }; "oauthConsent.scopes.sites": { count: number }; "oauthConsent.title": { client: string | number }; - "oauthConsent.titleFrom": { client: string | number; host: string | number }; + "oauthConsent.titleFrom": { host: string | number; client: string | number }; "oauthConsent.who.fromHost": { host: string | number }; "oauthConsent.who.lookalike": { client: string | number }; "oauthConsent.who.sendsBack": { host: string | number }; + "oauthConsent.who.thisComputerApp": { scheme: string | number }; "overview.commentsWaiting": { count: number }; "pageStatus.leftOut": { count: number }; "pageStatus.publishTitle": { title: string | number }; diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts index a3bdb8e7..35236550 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ar.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ar.ts @@ -1,12 +1,12 @@ // 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: a854ea3aec15d2d2 +// hash: c7bc7dea9efce92e import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "ربط {client}", - titleFrom: "ربط {client} من {host}", + titleFrom: "{host}: ربط {client}", description: "يطلب وكيل العمل في هذا الـStudio نيابةً عنك. تحقّق مما يُسمح له به قبل الموافقة.", invalid: { title: "لا يمكن استخدام هذا الطلب", @@ -19,9 +19,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "مسجّل في هذا الـStudio", selfRegistered: "مسجّل ذاتيًا، غير مُتحقَّق منه", lookalike: - "يشبه الاسم {client}، لكن أي أحد يمكنه تسجيل عميل بأي اسم. وافق فقط إذا بدأت هذا الاتصال من {client} بنفسك وكان العنوان أدناه هو الذي يستخدمه.", + "يشبه الاسم {client}، لكن أي أحد يمكنه تسجيل عميل بأي اسم. وافق فقط إذا بدأت هذا الاتصال من {client} بنفسك وكان العنوان الذي يعيدك إليه، المعروض أعلاه، هو الذي يستخدمه.", sendsBack: "يعيدك إلى {host}", thisComputer: "يعيدك إلى تطبيق على هذا الحاسوب", + thisComputerApp: "يعيدك إلى تطبيق على هذا الكمبيوتر ({scheme}:)", loopbackWarning: "كل عنوان يمكن لهذا الوكيل أن يعيدك إليه موجود على هذا الحاسوب. وافق فقط إذا بدأت الاتصال من تطبيق هنا.", stepUp: "هذا الوكيل متصل بالفعل ويطلب المزيد. ما يُسمح له به اليوم محدَّد.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.de.ts b/apps/studio/src/i18n/lazy/oauthConsent.de.ts index 4d0149f9..18d9a3de 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.de.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.de.ts @@ -1,12 +1,12 @@ // 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: 2637d576f3883563 +// hash: d3fc2f9b7d88dce0 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} verbinden", - titleFrom: "{client} von {host} verbinden", + titleFrom: "{host}: {client} verbinden", description: "Ein Agent möchte in diesem Studio für dich arbeiten. Prüfe, was er darf, bevor du zustimmst.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "In diesem Studio registriert", selfRegistered: "Selbst registriert, nicht geprüft", lookalike: - "Der Name sieht nach {client} aus, aber jeder kann einen Client unter beliebigem Namen registrieren. Stimme nur zu, wenn du diese Verbindung selbst aus {client} gestartet hast und die Adresse unten die ist, die es verwendet.", + "Der Name sieht nach {client} aus, aber jeder kann einen Client unter beliebigem Namen registrieren. Stimme nur zu, wenn du diese Verbindung selbst aus {client} gestartet hast und die Adresse, zu der sie dich zurückschickt (oben), die ist, die es verwendet.", sendsBack: "Leitet dich zurück zu {host}", thisComputer: "Leitet dich zurück zu einer App auf diesem Computer", + thisComputerApp: "Schickt dich zu einer App auf diesem Computer zurück ({scheme}:)", 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: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.en.ts b/apps/studio/src/i18n/lazy/oauthConsent.en.ts index 0b05d666..00b60455 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.en.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.en.ts @@ -6,7 +6,7 @@ export const messages = { oauthConsent: { title: "Connect {client}", - titleFrom: "Connect {client} from {host}", + titleFrom: "{host}: connect {client}", description: "An agent asks to work in this Studio for you. Check what it may do before you approve.", invalid: { @@ -20,9 +20,10 @@ export const messages = { registered: "Registered with this Studio", selfRegistered: "Self-registered, not verified", lookalike: - "The name looks like {client}, but anyone can register a client under any name. Approve only if you started this connection from {client} yourself and the address below is the one it uses.", + "The name looks like {client}, but anyone can register a client under any name. Approve only if you started this connection from {client} yourself and the address it sends you back to, shown above, is the one it uses.", sendsBack: "Sends you back to {host}", thisComputer: "Sends you back to an app on this computer", + thisComputerApp: "Sends you back to an app on this computer ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.es.ts b/apps/studio/src/i18n/lazy/oauthConsent.es.ts index 651a25c5..26b85a03 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.es.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.es.ts @@ -1,12 +1,12 @@ // 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: 732e36cfbbdc92b4 +// hash: c0a9bf4916410522 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Conectar {client}", - titleFrom: "Conectar {client} desde {host}", + titleFrom: "{host}: conectar {client}", description: "Un agente pide trabajar en este Studio por ti. Revisa lo que puede hacer antes de aprobar.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Registrado en este Studio", selfRegistered: "Autorregistrado, sin verificar", lookalike: - "El nombre parece {client}, pero cualquiera puede registrar un cliente con cualquier nombre. Aprueba solo si iniciaste tú esta conexión desde {client} y la dirección de abajo es la que usa.", + "El nombre parece {client}, pero cualquiera puede registrar un cliente con cualquier nombre. Aprueba solo si iniciaste tú esta conexión desde {client} y la dirección a la que te devuelve, indicada arriba, es la que usa.", sendsBack: "Te devuelve a {host}", thisComputer: "Te devuelve a una app de este equipo", + thisComputerApp: "Te devuelve a una app de este equipo ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts index b40cca05..b89202b8 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fa.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fa.ts @@ -1,12 +1,12 @@ // 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: 7a8af8024fb008cf +// hash: 14b933da3ad6923c import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "اتصال {client}", - titleFrom: "اتصال {client} از {host}", + titleFrom: "{host}: اتصال {client}", description: "یک عامل می‌خواهد در این Studio از طرف شما کار کند. پیش از تأیید، ببینید چه کارهایی مجاز است.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "ثبت‌شده در این Studio", selfRegistered: "خودثبت‌شده، تأییدنشده", lookalike: - "این نام شبیه {client} است، اما هر کسی می‌تواند کلاینتی با هر نامی ثبت کند. فقط وقتی تأیید کن که خودت این اتصال را از {client} شروع کرده‌ای و نشانی زیر همان نشانی‌ای است که استفاده می‌کند.", + "این نام شبیه {client} است، اما هر کسی می‌تواند کلاینتی با هر نامی ثبت کند. فقط وقتی تأیید کن که خودت این اتصال را از {client} شروع کرده‌ای و نشانی‌ای که تو را به آن برمی‌گرداند (بالا) همان نشانی‌ای است که استفاده می‌کند.", sendsBack: "شما را به {host} برمی‌گرداند", thisComputer: "شما را به برنامه‌ای در این رایانه برمی‌گرداند", + thisComputerApp: "تو را به برنامه‌ای روی این رایانه برمی‌گرداند ({scheme}:)", loopbackWarning: "همهٔ نشانی‌هایی که این عامل می‌تواند شما را به آن‌ها برگرداند روی این رایانه است. فقط اگر اتصال را از برنامه‌ای در همین‌جا شروع کرده‌اید تأیید کنید.", stepUp: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts index b948e372..3735b966 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.fr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.fr.ts @@ -1,12 +1,12 @@ // 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: c059ed6a7785f66f +// hash: 89c891ee68bf3306 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Connecter {client}", - titleFrom: "Connecter {client} depuis {host}", + titleFrom: "{host} : connecter {client}", description: "Un agent demande à travailler dans ce Studio pour vous. Vérifiez ce qu'il peut faire avant d'approuver.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Enregistré dans ce Studio", selfRegistered: "Auto-inscrit, non vérifié", lookalike: - "Le nom ressemble à {client}, mais n’importe qui peut inscrire un client sous n’importe quel nom. N’approuvez que si vous avez lancé vous-même cette connexion depuis {client} et que l’adresse ci-dessous est celle qu’il utilise.", + "Le nom ressemble à {client}, mais n’importe qui peut inscrire un client sous n’importe quel nom. N’approuvez que si vous avez lancé vous-même cette connexion depuis {client} et que l’adresse vers laquelle elle vous renvoie, indiquée ci-dessus, est celle qu’il utilise.", sendsBack: "Vous renvoie vers {host}", thisComputer: "Vous renvoie vers une application de cet ordinateur", + thisComputerApp: "Vous renvoie vers une application de cet ordinateur ({scheme}:)", 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: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.he.ts b/apps/studio/src/i18n/lazy/oauthConsent.he.ts index 416af7e9..37bbe1d2 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.he.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.he.ts @@ -1,12 +1,12 @@ // 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: 69b6f06bb5da7e88 +// hash: 3f94b9b19b92115e import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "חיבור {client}", - titleFrom: "חיבור {client} מ-{host}", + titleFrom: "{host}: חיבור {client}", description: "סוכן מבקש לעבוד ב-Studio הזה בשמך. כדאי לבדוק מה מותר לו לפני האישור.", invalid: { title: "אי אפשר להשתמש בבקשה הזו", @@ -19,9 +19,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "רשום ב-Studio הזה", selfRegistered: "נרשם בעצמו, לא מאומת", lookalike: - "השם נראה כמו {client}, אבל כל אחד יכול לרשום לקוח בכל שם. אשר רק אם אתה התחלת את החיבור הזה מ-{client} והכתובת שלמטה היא זו שהוא משתמש בה.", + "השם נראה כמו {client}, אבל כל אחד יכול לרשום לקוח בכל שם. אשר רק אם אתה התחלת את החיבור הזה מ-{client} והכתובת שאליה הוא מחזיר אותך, המוצגת למעלה, היא זו שהוא משתמש בה.", sendsBack: "יחזיר אותך אל {host}", thisComputer: "יחזיר אותך לאפליקציה במחשב הזה", + thisComputerApp: "מחזיר אותך לאפליקציה במחשב הזה ({scheme}:)", loopbackWarning: "כל כתובת שהסוכן הזה יכול להחזיר אותך אליה נמצאת במחשב הזה. יש לאשר רק אם התחלת את החיבור מאפליקציה כאן.", stepUp: "הסוכן הזה כבר מחובר ומבקש יותר. מה שמותר לו היום מסומן.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts index 638b361d..6f143391 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.hi.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.hi.ts @@ -1,12 +1,12 @@ // 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: 84204e6400b36dea +// hash: ca9d9114af0b5c08 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} कनेक्ट करें", - titleFrom: "{host} से {client} कनेक्ट करें", + titleFrom: "{host}: {client} कनेक्ट करें", description: "एक एजेंट इस Studio में आपकी ओर से काम करना चाहता है। मंज़ूरी देने से पहले देखें कि वह क्या कर सकता है।", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "इस Studio में रजिस्टर्ड", selfRegistered: "स्वयं पंजीकृत, सत्यापित नहीं", lookalike: - "यह नाम {client} जैसा दिखता है, लेकिन कोई भी किसी भी नाम से क्लाइंट पंजीकृत कर सकता है। केवल तभी स्वीकृत करें जब आपने यह कनेक्शन खुद {client} से शुरू किया हो और नीचे दिया गया पता वही हो जिसका वह उपयोग करता है।", + "यह नाम {client} जैसा दिखता है, लेकिन कोई भी किसी भी नाम से क्लाइंट पंजीकृत कर सकता है। केवल तभी स्वीकृत करें जब आपने यह कनेक्शन खुद {client} से शुरू किया हो और ऊपर दिखाया गया वापसी पता वही हो जिसका वह उपयोग करता है।", sendsBack: "आपको {host} पर वापस भेजता है", thisComputer: "आपको इस कंप्यूटर के किसी ऐप पर वापस भेजता है", + thisComputerApp: "आपको इस कंप्यूटर के एक ऐप पर वापस भेजता है ({scheme}:)", loopbackWarning: "यह एजेंट आपको जिन भी पतों पर वापस भेज सकता है, वे सब इसी कंप्यूटर पर हैं। मंज़ूरी तभी दें जब आपने कनेक्शन यहीं के किसी ऐप से शुरू किया हो।", stepUp: "यह एजेंट पहले से कनेक्टेड है और और ज़्यादा मांग रहा है। आज वह जो कर सकता है, वह चुना हुआ है।", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.id.ts b/apps/studio/src/i18n/lazy/oauthConsent.id.ts index a90222c7..56ab642f 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.id.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.id.ts @@ -1,12 +1,12 @@ // 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: 24728cb99a81ccfd +// hash: c54a2392a96c5282 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Hubungkan {client}", - titleFrom: "Hubungkan {client} dari {host}", + titleFrom: "{host}: hubungkan {client}", description: "Sebuah agen meminta untuk bekerja di Studio ini atas nama Anda. Periksa apa yang boleh dilakukannya sebelum menyetujui.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Terdaftar di Studio ini", selfRegistered: "Terdaftar sendiri, belum diverifikasi", lookalike: - "Namanya mirip {client}, tetapi siapa pun bisa mendaftarkan klien dengan nama apa pun. Setujui hanya jika kamu sendiri memulai koneksi ini dari {client} dan alamat di bawah adalah yang dipakainya.", + "Namanya mirip {client}, tetapi siapa pun bisa mendaftarkan klien dengan nama apa pun. Setujui hanya jika kamu sendiri memulai koneksi ini dari {client} dan alamat tujuan kembali yang ditampilkan di atas adalah yang dipakainya.", sendsBack: "Mengembalikan Anda ke {host}", thisComputer: "Mengembalikan Anda ke aplikasi di komputer ini", + thisComputerApp: "Mengembalikanmu ke aplikasi di komputer ini ({scheme}:)", loopbackWarning: "Setiap alamat tujuan agen ini mengembalikan Anda ada di komputer ini. Setujui hanya jika Anda memulai koneksi dari aplikasi di sini.", stepUp: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.it.ts b/apps/studio/src/i18n/lazy/oauthConsent.it.ts index c7082fc0..615f3eb3 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.it.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.it.ts @@ -1,12 +1,12 @@ // 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: 25a76d3cc2ce99ad +// hash: b8b6c8b1978e6cd1 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Collega {client}", - titleFrom: "Collega {client} da {host}", + titleFrom: "{host}: collega {client}", description: "Un agente chiede di lavorare in questo Studio per te. Controlla cosa può fare prima di approvare.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Registrato in questo Studio", selfRegistered: "Registrato da sé, non verificato", lookalike: - "Il nome sembra {client}, ma chiunque può registrare un client con qualsiasi nome. Approva solo se hai avviato tu questa connessione da {client} e l’indirizzo qui sotto è quello che usa.", + "Il nome sembra {client}, ma chiunque può registrare un client con qualsiasi nome. Approva solo se hai avviato tu questa connessione da {client} e l’indirizzo a cui ti riporta, indicato sopra, è quello che usa.", sendsBack: "Ti rimanda a {host}", thisComputer: "Ti rimanda a un'app su questo computer", + thisComputerApp: "Ti riporta a un’app su questo computer ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts index ab78aae1..91cc1554 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ja.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ja.ts @@ -1,12 +1,12 @@ // 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: f83bb9fdc3583319 +// hash: 4d2e18542f31f2de import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} を接続", - titleFrom: "{host} の {client} を接続", + titleFrom: "{host}: {client} を接続", description: "エージェントがあなたの代わりにこの Studio で作業することを求めています。承認する前に、できることを確認してください。", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "この Studio に登録済み", selfRegistered: "自己登録、未確認", lookalike: - "名前は {client} に見えますが、誰でも任意の名前でクライアントを登録できます。この接続を自分で {client} から開始し、下のアドレスがそのクライアントの使うものである場合にのみ承認してください。", + "名前は {client} に見えますが、誰でも任意の名前でクライアントを登録できます。この接続を自分で {client} から開始し、上に表示された戻り先のアドレスがそのクライアントの使うものである場合にのみ承認してください。", sendsBack: "{host} に戻ります", thisComputer: "このコンピューター上のアプリに戻ります", + thisComputerApp: "このコンピューターのアプリに戻ります ({scheme}:)", loopbackWarning: "このエージェントが戻す先のアドレスはすべてこのコンピューター上にあります。ここにあるアプリから接続を始めた場合にのみ承認してください。", stepUp: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts index 6ef789bc..2cf86260 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ko.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ko.ts @@ -1,12 +1,12 @@ // 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: b1ab9fd30aa09b44 +// hash: 46d4ba78d2e9409c import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} 연결", - titleFrom: "{host}의 {client} 연결", + titleFrom: "{host}: {client} 연결", description: "에이전트가 이 Studio에서 나를 대신해 일하겠다고 요청합니다. 승인하기 전에 할 수 있는 일을 확인하세요.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "이 Studio에 등록됨", selfRegistered: "자체 등록됨, 확인되지 않음", lookalike: - "이름이 {client}처럼 보이지만 누구나 어떤 이름으로든 클라이언트를 등록할 수 있습니다. 직접 {client}에서 이 연결을 시작했고 아래 주소가 그 클라이언트가 쓰는 주소일 때만 승인하세요.", + "이름이 {client}처럼 보이지만 누구나 어떤 이름으로든 클라이언트를 등록할 수 있습니다. 직접 {client}에서 이 연결을 시작했고 위에 표시된 돌아갈 주소가 그 클라이언트가 쓰는 주소일 때만 승인하세요.", sendsBack: "{host}(으)로 돌아갑니다", thisComputer: "이 컴퓨터의 앱으로 돌아갑니다", + thisComputerApp: "이 컴퓨터의 앱으로 돌아갑니다 ({scheme}:)", loopbackWarning: "이 에이전트가 돌려보낼 수 있는 주소는 모두 이 컴퓨터에 있습니다. 이 컴퓨터의 앱에서 연결을 시작한 경우에만 승인하세요.", stepUp: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts index 7007dbfa..9975a91e 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.nl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.nl.ts @@ -1,12 +1,12 @@ // 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: 23243900d9cc5640 +// hash: 7ff408a54a6c971a import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} verbinden", - titleFrom: "{client} van {host} koppelen", + titleFrom: "{host}: {client} koppelen", description: "Een agent vraagt om voor je in deze Studio te werken. Controleer wat hij mag voordat je goedkeurt.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Geregistreerd bij deze Studio", selfRegistered: "Zelf geregistreerd, niet geverifieerd", lookalike: - "De naam lijkt op {client}, maar iedereen kan een client onder elke naam registreren. Keur alleen goed als je deze verbinding zelf vanuit {client} hebt gestart en het adres hieronder het adres is dat het gebruikt.", + "De naam lijkt op {client}, maar iedereen kan een client onder elke naam registreren. Keur alleen goed als je deze verbinding zelf vanuit {client} hebt gestart en het adres waarnaar ze je terugstuurt, hierboven, het adres is dat het gebruikt.", sendsBack: "Stuurt je terug naar {host}", thisComputer: "Stuurt je terug naar een app op deze computer", + thisComputerApp: "Stuurt je terug naar een app op deze computer ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts index 576236f5..66818ed7 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pl.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pl.ts @@ -1,12 +1,12 @@ // 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: 7347607b5df34bb4 +// hash: aa6ae745d3f932f5 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Połącz {client}", - titleFrom: "Połącz {client} z {host}", + titleFrom: "{host}: połącz {client}", description: "Agent prosi o pracę w tym Studio w twoim imieniu. Sprawdź, co może robić, zanim zatwierdzisz.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Zarejestrowany w tym Studio", selfRegistered: "Zarejestrowany samodzielnie, niezweryfikowany", lookalike: - "Nazwa wygląda jak {client}, ale każdy może zarejestrować klienta pod dowolną nazwą. Zatwierdź tylko, jeśli to ty rozpocząłeś to połączenie z {client}, a adres poniżej jest tym, którego używa.", + "Nazwa wygląda jak {client}, ale każdy może zarejestrować klienta pod dowolną nazwą. Zatwierdź tylko, jeśli to ty rozpocząłeś to połączenie z {client}, a adres powrotu pokazany powyżej jest tym, którego używa.", sendsBack: "Odeśle cię do {host}", thisComputer: "Odeśle cię do aplikacji na tym komputerze", + thisComputerApp: "Odsyła cię do aplikacji na tym komputerze ({scheme}:)", 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: diff --git a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts index 8f06447c..583c7eb7 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.pt-BR.ts @@ -1,12 +1,12 @@ // 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: 2d03f9147ddc9589 +// hash: ad075ac96f19d9a4 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Conectar {client}", - titleFrom: "Conectar {client} de {host}", + titleFrom: "{host}: conectar {client}", description: "Um agente pede para trabalhar neste Studio por você. Confira o que ele pode fazer antes de aprovar.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Registrado neste Studio", selfRegistered: "Autorregistrado, não verificado", lookalike: - "O nome parece {client}, mas qualquer pessoa pode registrar um cliente com qualquer nome. Aprove só se você mesmo iniciou esta conexão pelo {client} e o endereço abaixo é o que ele usa.", + "O nome parece {client}, mas qualquer pessoa pode registrar um cliente com qualquer nome. Aprove só se você mesmo iniciou esta conexão pelo {client} e o endereço para onde ele leva você, mostrado acima, é o que ele usa.", sendsBack: "Leva você de volta para {host}", thisComputer: "Leva você de volta para um app neste computador", + thisComputerApp: "Leva você de volta a um app deste computador ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts index af910d1b..271decdb 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.ru.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.ru.ts @@ -1,12 +1,12 @@ // 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: d5adc4645fcf9f74 +// hash: df5de542275cbd08 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Подключить {client}", - titleFrom: "Подключить {client} с {host}", + titleFrom: "{host}: подключить {client}", description: "Агент просит работать в этом Studio от вашего имени. Проверьте, что ему можно, прежде чем разрешить.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Зарегистрирован в этом Studio", selfRegistered: "Зарегистрирован сам, не проверен", lookalike: - "Название похоже на {client}, но кто угодно может зарегистрировать клиента под любым именем. Одобряйте, только если вы сами начали это подключение из {client} и адрес ниже — тот, который он использует.", + "Название похоже на {client}, но кто угодно может зарегистрировать клиента под любым именем. Одобряйте, только если вы сами начали это подключение из {client} и адрес возврата, указанный выше, — тот, который он использует.", sendsBack: "Вернёт вас на {host}", thisComputer: "Вернёт вас в приложение на этом компьютере", + thisComputerApp: "Возвращает вас в приложение на этом компьютере ({scheme}:)", loopbackWarning: "Все адреса, на которые этот агент может вас вернуть, находятся на этом компьютере. Разрешайте, только если подключение начато из приложения здесь.", stepUp: "Этот агент уже подключён и просит больше. То, что ему можно сейчас, отмечено.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts index f4b21fc7..a04b11d5 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.tr.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.tr.ts @@ -1,12 +1,12 @@ // 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: 3b2ad4ef50cd21c0 +// hash: 9cd67ab61daa4c7b import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "{client} bağla", - titleFrom: "{host} üzerinden {client} bağla", + titleFrom: "{host}: {client} bağla", description: "Bir agent bu Studio'da senin adına çalışmak istiyor. Onaylamadan önce neler yapabileceğine bak.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Bu Studio'ya kayıtlı", selfRegistered: "Kendini kaydetmiş, doğrulanmamış", lookalike: - "Ad {client} gibi görünüyor ama herkes herhangi bir adla istemci kaydedebilir. Yalnızca bu bağlantıyı {client} üzerinden kendin başlattıysan ve aşağıdaki adres onun kullandığı adresse onayla.", + "Ad {client} gibi görünüyor ama herkes herhangi bir adla istemci kaydedebilir. Yalnızca bu bağlantıyı {client} üzerinden kendin başlattıysan ve yukarıda gösterilen dönüş adresi onun kullandığı adresse onayla.", sendsBack: "Seni {host} adresine geri gönderir", thisComputer: "Seni bu bilgisayardaki bir uygulamaya geri gönderir", + thisComputerApp: "Seni bu bilgisayardaki bir uygulamaya geri gönderir ({scheme}:)", 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.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts index faf4d322..b3b13b80 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.uk.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.uk.ts @@ -1,12 +1,12 @@ // 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: a0a05410b77f41f5 +// hash: 0a57d72b191bd1f8 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "Під'єднати {client}", - titleFrom: "Підключити {client} з {host}", + titleFrom: "{host}: підключити {client}", description: "Агент просить працювати в цьому Studio від вашого імені. Перевірте, що йому можна, перш ніж дозволити.", invalid: { @@ -20,9 +20,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "Зареєстрований у цьому Studio", selfRegistered: "Зареєстрований сам, не перевірений", lookalike: - "Назва схожа на {client}, але будь-хто може зареєструвати клієнта під будь-якою назвою. Схвалюйте, лише якщо ви самі почали це підключення з {client} і адреса нижче — та, яку він використовує.", + "Назва схожа на {client}, але будь-хто може зареєструвати клієнта під будь-якою назвою. Схвалюйте, лише якщо ви самі почали це підключення з {client} і адреса повернення, показана вище, — та, яку він використовує.", sendsBack: "Поверне вас на {host}", thisComputer: "Поверне вас у застосунок на цьому комп'ютері", + thisComputerApp: "Повертає вас до застосунку на цьому комп’ютері ({scheme}:)", loopbackWarning: "Усі адреси, на які цей агент може вас повернути, на цьому комп'ютері. Дозволяйте, лише якщо під'єднання почато із застосунку тут.", stepUp: "Цей агент уже під'єднаний і просить більше. Те, що йому можна зараз, позначено.", diff --git a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts index 73c6d62a..a6d2a43b 100644 --- a/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts +++ b/apps/studio/src/i18n/lazy/oauthConsent.zh-Hans.ts @@ -1,12 +1,12 @@ // 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: a00f4f54efbcdb56 +// hash: c6ec3e4e73790371 import type { NamespaceMessages } from "../namespaces.ts"; export const messages: NamespaceMessages<"oauthConsent"> = { oauthConsent: { title: "连接 {client}", - titleFrom: "连接来自 {host} 的 {client}", + titleFrom: "{host}:连接 {client}", description: "有个代理请求代表你在这个 Studio 中工作。批准前请先查看它可以做什么。", invalid: { title: "无法使用此请求", @@ -18,9 +18,10 @@ export const messages: NamespaceMessages<"oauthConsent"> = { registered: "已在此 Studio 注册", selfRegistered: "自行注册,未经验证", lookalike: - "这个名称看起来像 {client},但任何人都可以用任意名称注册客户端。只有当你亲自从 {client} 发起此连接,并且下方地址正是它使用的地址时才批准。", + "这个名称看起来像 {client},但任何人都可以用任意名称注册客户端。只有当你亲自从 {client} 发起此连接,并且上方显示的返回地址正是它使用的地址时才批准。", sendsBack: "会把你送回 {host}", thisComputer: "会把你送回这台电脑上的应用", + thisComputerApp: "将你送回这台电脑上的应用({scheme}:)", loopbackWarning: "这个代理能把你送回的所有地址都在这台电脑上。只有当你是从这里的应用发起连接时才批准。", stepUp: "这个代理已经连接,现在请求更多权限。它目前能做的已勾选。", diff --git a/apps/studio/src/lib/api/agent-grants.ts b/apps/studio/src/lib/api/agent-grants.ts index 09ed332a..972bb991 100644 --- a/apps/studio/src/lib/api/agent-grants.ts +++ b/apps/studio/src/lib/api/agent-grants.ts @@ -21,10 +21,11 @@ export interface ConsentInfo { /** `document`: a metadata-document client, identified by `host`; `self-registered`: DCR. */ kind: "document" | "self-registered" | "server"; host?: string; - /** A self-registered client's name that contains a well-known client's. */ + /** The well-known client the name looks like, when this is not that client. */ lookalike?: string; }; - redirect: { host: string; loopback: boolean }; + /** `app`: a native app's private-use scheme (no web host). */ + redirect: { host: string; loopback: boolean; app?: string }; loopbackOnly: boolean; scopes: ConsentScope[]; offlineAccess: boolean; diff --git a/apps/studio/src/routes/OAuthConsentPage.tsx b/apps/studio/src/routes/OAuthConsentPage.tsx index aecdc969..bd2573c1 100644 --- a/apps/studio/src/routes/OAuthConsentPage.tsx +++ b/apps/studio/src/routes/OAuthConsentPage.tsx @@ -120,44 +120,7 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo }) return (
{decide.isError && } - } title={c("oauthConsent.who.title")}> -
- -
- {info.client.host && ( - {c("oauthConsent.who.fromHost", { host: info.client.host })} - )} - {info.client.name} - {!info.client.host && ( - - {info.client.kind === "self-registered" - ? c("oauthConsent.who.selfRegistered") - : c("oauthConsent.who.registered")} - - )} - - {info.redirect.loopback - ? c("oauthConsent.who.thisComputer") - : c("oauthConsent.who.sendsBack", { host: info.redirect.host })} - -
-
- {info.client.lookalike && ( - - )} - {info.loopbackOnly && ( - } - description={c("oauthConsent.who.loopbackWarning")} - /> - )} - {info.existing && } -
-
+ } title={c("oauthConsent.scopes.title")} @@ -248,3 +211,53 @@ function ConsentForm({ request, info }: { request: string; info: ConsentInfo })
); } + +/** + * Who is asking (§4 item 1): a document client's host first, the self-asserted name, how the + * server knows the client, where the browser goes back to, and the warnings. + */ +function WhoIsAsking({ info }: { info: ConsentInfo }) { + const c = useT("oauthConsent"); + return ( + } title={c("oauthConsent.who.title")}> +
+ +
+ {info.client.host && ( + {c("oauthConsent.who.fromHost", { host: info.client.host })} + )} + {info.client.name} + {!info.client.host && ( + + {info.client.kind === "self-registered" + ? c("oauthConsent.who.selfRegistered") + : c("oauthConsent.who.registered")} + + )} + + {info.redirect.app + ? c("oauthConsent.who.thisComputerApp", { scheme: info.redirect.app }) + : info.redirect.loopback + ? c("oauthConsent.who.thisComputer") + : c("oauthConsent.who.sendsBack", { host: info.redirect.host })} + +
+
+ {info.client.lookalike && ( + + )} + {info.loopbackOnly && ( + } + description={c("oauthConsent.who.loopbackWarning")} + /> + )} + {info.existing && } +
+
+ ); +} diff --git a/apps/studio/test/oauth-consent.test.tsx b/apps/studio/test/oauth-consent.test.tsx index 04b05bdf..c47f5ee0 100644 --- a/apps/studio/test/oauth-consent.test.tsx +++ b/apps/studio/test/oauth-consent.test.tsx @@ -58,7 +58,7 @@ describe("OAuth consent screen", () => { ); renderPage(); // The document's host is in the title and leads the identity, whatever the name says. - expect(await screen.findByText("Connect Claude Code from claude.ai")).toBeTruthy(); + expect(await screen.findByText("claude.ai: 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. @@ -100,6 +100,27 @@ describe("OAuth consent screen", () => { expect(screen.getByText("Sends you back to claude-ai.callback.example")).toBeTruthy(); }); + it("shows a private-use redirect as an app on this computer, never as a web host", async () => { + serve( + ...asRole("owner"), + on("GET", "/api/agent-grants/consent", { + status: 200, + body: { + ...INFO, + client: { id: "dcr2", name: "Cursor", kind: "self-registered" }, + redirect: { host: "", loopback: true, app: "cursor" }, + loopbackOnly: false, + }, + }), + ); + renderPage(); + expect( + await screen.findByText("Sends you back to an app on this computer (cursor:)"), + ).toBeTruthy(); + // Cursor registers itself under its own name: no look-alike warning for it. + expect(screen.queryByText(/The name looks like/)).toBeNull(); + }); + it("asks for explicit consent before an Auto connection", async () => { const fetch = serve( ...asRole("owner"), diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index f8b01399..cd58df2a 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -16,13 +16,13 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve 6. **Registration (Phase 3a, amended 2026-11-09).** Phase 2 had none and a server-side test client (`OauthServer.createClient`); Phase 3a replaces it with the two paths of RFC-0044 §3, and `createClient` is gone (the suites register through DCR or CIMD; `clientPrivileges` now admits nobody). See "Phase 3a" below. ## Phase 3a: client registration (2026-11-09) -1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile. Around it: a stored client validated within 60 min (its row's `updatedAt`) is used without a fetch on every runtime (a Worker rebuilds the plugin and its cache per request); fetches spend budgets in `rate_limits` — 20 per caller address per minute, and only for documents not stored yet 30 per origin and 120 in all — and a stored client is served from its row when a budget is spent (stale-if-exhausted), so anonymous calls cannot lock members' clients out; at most 5,000 document clients are stored (503), and one with no connection and no fetch for a day is pruned. A failed refetch of a stored client is refused. `CLOUDHAL_MCP_CIMD=off` turns it off. +1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile; its own cache is off and its fetch windows are set out of reach, so the stored row is the only cache and Cloudhal's budgets the only budgets, on Workers (instance per request) and Node (instance per process) alike. A stored client validated within 60 min (its row's `updatedAt`) is used without a fetch. Every fetch spends the caller's budget (20 per address per minute, past which that caller gets 429); a document not stored yet also spends 30 per origin and 120 in all per minute, and needs room under the cap of 5,000 (the oldest grantless client not fetched for 10 min is evicted, else 503; the well-known clients' document hosts and the host list are exempt from the cap and never evicted). A failed refetch is recorded on the row (its validation time cleared) and refuses the client until a fetch succeeds; a stored row is served as it is only when the plugin itself declines to fetch, and only within 24 h of its last good fetch. Unused document clients are pruned after three hours, up to four batches of 50 an hour; an index on `("clientDiscoveryId", "updatedAt")` (migration `0032`) serves the count, the eviction and the prune. `CLOUDHAL_MCP_CIMD=off` turns it off and also ends existing document clients' connections at their next refresh (they no longer resolve), the conservative choice. 2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers (document clients after a day), with every provider row of theirs in one batch per kind. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`, and `/oauth2/authorize` refuses the clients DCR stored earlier; connections they already have keep working until revoked or pruned. 3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host; http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching); or a native app's private-use scheme (`scheme:/…`, matched exactly) that is reverse-domain or a known app's (`cursor`, `vscode`, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), never `javascript`/`data`/`file`/`vbscript`/`about`/`blob` and friends or an `http` look-alike; at most 10, one refused entry refusing the registration. The rules and the host list run at registration (DCR) and at every authorization (`validateRedirectUri`). 4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from (stored clients included) and every client may redirect to (`localhost` admits loopback; private-use redirects then need a known scheme). A value that does not parse turns registration off. 5. **AS metadata** advertises `client_id_metadata_document_supported` with CIMD on, `registration_endpoint` with DCR on, and `token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]` (the provider's shared-secret methods are dropped: no client here holds a secret). -6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; it is notified (`schedule-approval-due`) for as long as it waits, and a person approves it in Studio with their own approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken. -7. **Consent identity** (`client-identity.ts`): a client name is shown plain (control, bidi and zero-width characters removed, spaces collapsed) and at most 100 characters; a document client leads with its host, in the title too; a DCR client is "self-registered, not verified", flagged when its name contains a well-known client's. +6. **`set_embargo` from a connection** files the gate-only release as a `schedule_release` request (`on-approval`, bound to `rl1:`) instead of saving the gate without effect; it is notified (`schedule-approval-due`) while its request is pending and unexpired, and a person approves it in Studio with their own approval. A grant-bound `auto` approval would need a contract change (the `auto` approval names a key) and is not taken. +7. **Consent identity** (`client-identity.ts`): a client name is shown plain (control, bidi, zero-width and format characters removed, at most two combining marks per character, spaces collapsed) and at most 100 characters; a document client leads with its host, first in the title; a DCR client is "self-registered, not verified"; a name that looks like a well-known document client's (Claude, Claude Code, ChatGPT, Codex, VS Code, compared after NFKC and a small confusable map) is flagged unless the client is that client's document on its real host. A UTS #39 confusable skeleton is a follow-up. A private-use redirect is shown as an app on this computer. ## Alternatives considered | Alternative | Pros | Cons | Why not | diff --git a/docs/guides/cloudflare-hardening.md b/docs/guides/cloudflare-hardening.md index 95bae3ed..3b1ba7ab 100644 --- a/docs/guides/cloudflare-hardening.md +++ b/docs/guides/cloudflare-hardening.md @@ -82,9 +82,9 @@ Claude, ChatGPT, Cursor, VS Code and the MCP Inspector connect to `https:// Promise; + /** + * Deletes the oldest document client with no live connection and no use since `before`, + * whose host is not in `exempt`; how many went (0 or 1). + */ + readonly evict: (input: { before: Date; exempt: readonly string[] }) => Promise; + /** Records a failed refetch on the row (its validation time cleared). */ + readonly markFailed: (clientId: string) => Promise; /** One hit of a fixed-window counter (`StaffStore.hit`); false once `max` is passed. */ readonly hit: (key: string, windowMs: number, max: number) => Promise; /** The caller's address from the runtime's trusted header. */ @@ -54,22 +74,43 @@ const unavailable = (status: "TOO_MANY_REQUESTS" | "SERVICE_UNAVAILABLE", descri function updatedAtOf(client: NonNullable): number { const value = (client as { updatedAt?: Date | string | number | null }).updatedAt; - return value === null || value === undefined ? Number.NaN : new Date(value).getTime(); + const ms = value === null || value === undefined ? Number.NaN : new Date(value).getTime(); + return Number.isNaN(ms) ? 0 : ms; } -/** Spends one fetch: the caller's budget, and for a new document its origin's and the total. */ -async function spend(guard: CimdGuard, url: URL, stored: boolean, address: string) { - const { limits } = guard; - if (!(await guard.hit(`cloudhal:cimd:addr:${address}`, 60_000, limits.perAddressPerMinute))) - return false; - if (stored) return true; +/** Hosts exempt from the cap and from eviction: the host list's and the well-known clients'. */ +export function exemptHosts(guard: Pick): string[] { + return [...WELL_KNOWN_DOCUMENT_HOSTS, ...(guard.clientHosts ?? [])]; +} + +function isExempt(host: string, exempt: readonly string[]): boolean { + return exempt.some( + (p) => hostMatches(host, p) || (!p.startsWith("*.") && hostMatches(host, `*.${p}`)), + ); +} + +/** The plugin's own "not now" (its fetch windows): no fetch happened. */ +function pluginDeclined(e: unknown): boolean { return ( - (await guard.hit(`cloudhal:cimd:origin:${url.origin}`, 60_000, limits.perOriginPerMinute)) && - (await guard.hit("cloudhal:cimd:all", 60_000, limits.perMinute)) + e instanceof APIError && + (e.body as { error?: unknown } | undefined)?.error === "temporarily_unavailable" ); } -async function guardedResolve( +/** Room for a document not stored yet (`maxClients`), evicting an unused one if needed. */ +async function admit(guard: CimdGuard, host: string): Promise { + const exempt = exemptHosts(guard); + if (isExempt(host, exempt) || (await guard.count()) < guard.limits.maxClients) return; + const before = new Date(guard.now().getTime() - guard.limits.evictMinutes * 60_000); + if ((await guard.evict({ before, exempt })) > 0) return; + throw unavailable( + "SERVICE_UNAVAILABLE", + "this server stores no more clients right now; try again later", + ); +} + +/** Exported for the suite's unit cases; the plugin calls it through `guardedCimd`. */ +export async function guardedResolve( inner: ClientDiscovery, guard: CimdGuard, input: { ctx: GenericEndpointContext; clientId: string; existing: StoredClient }, @@ -77,6 +118,7 @@ async function guardedResolve( const { ctx, clientId, existing } = input; if (!inner.matches(clientId)) return null; const url = new URL(clientId); + const { limits } = guard; const hosts = guard.clientHosts; if (hosts && hosts.length > 0 && !hosts.some((p) => hostMatches(url.hostname, p))) { if (existing) return null; @@ -85,26 +127,36 @@ async function guardedResolve( error_description: "client_id URL is not on this server's list of client hosts", }); } - const freshMs = guard.limits.freshMinutes * 60_000; - if (existing && guard.now().getTime() - updatedAtOf(existing) < freshMs) return existing; - if (!existing && (await guard.count()) >= guard.limits.maxClients) - throw unavailable( - "SERVICE_UNAVAILABLE", - "this server stores no more clients right now; try again later", - ); - if (!(await spend(guard, url, Boolean(existing), guard.address(ctx.headers)))) { - if (existing) return existing; - throw unavailable( - "TOO_MANY_REQUESTS", - "too many client metadata fetches; try again in a minute", - ); + const age = existing ? guard.now().getTime() - updatedAtOf(existing) : Number.POSITIVE_INFINITY; + if (existing && age < limits.freshMinutes * 60_000) return existing; + const tooMany = () => + unavailable("TOO_MANY_REQUESTS", "too many client metadata fetches; try again in a minute"); + const address = guard.address(ctx.headers); + if (!(await guard.hit(`cloudhal:cimd:addr:${address}`, 60_000, limits.perAddressPerMinute))) + throw tooMany(); + if (!existing) { + await admit(guard, url.hostname); + if ( + !(await guard.hit(`cloudhal:cimd:origin:${url.origin}`, 60_000, limits.perOriginPerMinute)) || + !(await guard.hit("cloudhal:cimd:all", 60_000, limits.perMinute)) + ) + throw tooMany(); + } + try { + return await inner.resolve(ctx, clientId, existing); + } catch (e) { + if (existing && pluginDeclined(e) && age < limits.maxStaleHours * 3_600_000) return existing; + if (existing && !pluginDeclined(e)) await guard.markFailed(clientId); + throw e; } - return inner.resolve(ctx, clientId, existing); } +/** Plugin windows Cloudhal's budgets always reach first (the plugin requires positive integers). */ +const OUT_OF_REACH = 1_000_000; + /** * The `cimd()` plugin with Cloudhal's guard (same id, MCP 2026-07-28 profile, the runtime's - * transport). Each instance keeps the plugin's bounded cache (500 entries). + * transport). */ export function guardedCimd( fetch: ClientMetadataResourceFetch, @@ -113,8 +165,18 @@ export function guardedCimd( const options: CimdOptions = { fetchClientMetadataResource: fetch, metadataProfile: "mcp-2026-07-28", - metadataRevalidationInterval: guard.limits.freshMinutes * 60, + // The stored row is the cache (step 2): the plugin's own would outlive a changed document. + metadataRevalidationInterval: 0, maxCacheEntries: 500, + // Cloudhal's budgets in `rate_limits` are the only ones (steps 3 and 4): on Node the + // plugin's windows would last the life of the process and could be drained anonymously. + metadataFetchPolicy: { + minimumFetchInterval: 0, + maximumConcurrentFetches: OUT_OF_REACH, + maximumConcurrentFetchesPerOrigin: OUT_OF_REACH, + maximumFetchesPerMinute: OUT_OF_REACH, + maximumFetchesPerOriginPerMinute: OUT_OF_REACH, + }, }; const inner = createCimdClientDiscovery(options); const discovery: ClientDiscovery = { diff --git a/packages/api/src/auth/cimd-guard.ts b/packages/api/src/auth/cimd-guard.ts new file mode 100644 index 00000000..86b8152d --- /dev/null +++ b/packages/api/src/auth/cimd-guard.ts @@ -0,0 +1,28 @@ +/** + * The CIMD guard's wiring (`cimd-discovery.ts`): Cloudhal's `rate_limits` budgets, the cap and + * its eviction, failure marking on the row, and the caller's trusted address. + */ +import type { StaffStore } from "../staff.ts"; +import type { CimdGuard } from "./cimd-discovery.ts"; +import { countCimdClients, evictCimdClient, markCimdFetchFailed } from "./oauth-client-rows.ts"; +import { cimdLimits } from "./oauth-registration.ts"; +import type { AuthOptions } from "./types.ts"; + +export function cimdGuardFor( + options: AuthOptions, + staff: StaffStore, + runtime: { readonly now: () => Date; readonly ipHeader: string | undefined }, +): CimdGuard { + const registration = options.mcpOauth?.registration; + return { + limits: cimdLimits(registration), + clientHosts: registration?.clientHosts, + count: () => countCimdClients(options.sql), + evict: (input) => evictCimdClient(options.sql, input), + markFailed: (clientId) => markCimdFetchFailed(options.sql, clientId), + hit: (key, windowMs, max) => staff.hit(key, windowMs, max), + // The runtime's trusted address (#165); without one, one shared bucket as elsewhere. + address: (headers) => headers?.get(runtime.ipHeader ?? "cf-connecting-ip") ?? "unknown", + now: runtime.now, + }; +} diff --git a/packages/api/src/auth/client-identity.ts b/packages/api/src/auth/client-identity.ts index cc9e09c9..023370b5 100644 --- a/packages/api/src/auth/client-identity.ts +++ b/packages/api/src/auth/client-identity.ts @@ -5,11 +5,16 @@ * can vouch for: * * - `document`: a Client ID Metadata Document client; the document URL's host is its identity. - * - `self-registered`: dynamic registration; nothing is verified, and a name that looks like a - * well-known client's is flagged (`lookalike`). + * - `self-registered`: dynamic registration; nothing is verified. * - `server`: a client the server itself stores (none are made today). * - * Linear only (AGENTS.md rule 13): one pass over the code points, no regular expression. + * A name that looks like one of the well-known document clients' (`lookalike`: Claude, Claude + * Code, ChatGPT, Codex, VS Code) is flagged unless the client is that client's document on its + * real host. Names are compared after NFKC and a small confusable map (Cyrillic and Greek + * look-alikes of Latin letters, `0`/`O`, `1`/`l`/`I`); a full UTS #39 skeleton is a follow-up. + * + * Linear only (AGENTS.md rule 13): one pass over the code points; the only pattern tests one + * character at a time. */ /** The longest name shown (as DCR's own limit). */ @@ -25,17 +30,24 @@ interface ClientRowIdentity { readonly referenceId?: string | null; } -/** Clients that identify themselves with a metadata document (or, for Cursor, register). */ -const WELL_KNOWN = [ - "Claude Code", - "Claude", - "ChatGPT", - "Codex", - "VS Code", - "Visual Studio Code", - "Cursor", +/** + * The well-known clients that identify themselves with a metadata document (RFC-0044 §11), and + * the hosts their documents live on. Cursor is not one: it always registers itself, so its own + * name is no sign of impersonation. + */ +const WELL_KNOWN: readonly { readonly name: string; readonly hosts: readonly string[] }[] = [ + { name: "Claude Code", hosts: ["claude.ai", "claude.com"] }, + { name: "Claude", hosts: ["claude.ai", "claude.com"] }, + { name: "ChatGPT", hosts: ["chatgpt.com", "openai.com"] }, + { name: "Codex", hosts: ["chatgpt.com", "openai.com"] }, + { name: "VS Code", hosts: ["vscode.dev", "github.com"] }, + { name: "Visual Studio Code", hosts: ["vscode.dev", "github.com"] }, ]; +/** At most this many combining marks are kept on one base character. */ +const MAX_MARKS = 2; +const MARK = /^\p{M}$/u; + /** Code points never shown: controls, bidi controls and marks, zero-width and other format characters. */ function hidden(cp: number): boolean { if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return true; // C0, DEL, C1 @@ -71,13 +83,20 @@ export function displayName(raw: string | null | undefined, fallback: string): s let out = ""; let length = 0; let pendingSpace = false; + let marks = 0; for (const ch of raw ?? "") { const cp = ch.codePointAt(0) ?? 0; if (hidden(cp)) continue; if (space(cp)) { pendingSpace = out.length > 0; + marks = 0; continue; } + if (MARK.test(ch)) { + // Stacked marks render tall over the lines around the name: a few per character only. + if (out.length === 0 || pendingSpace || marks >= MAX_MARKS) continue; + marks += 1; + } else marks = 0; if (pendingSpace) { if (length + 1 >= CLIENT_NAME_MAX) break; out += " "; @@ -97,18 +116,108 @@ export function clientKind(row: ClientRowIdentity): ClientKind { return "server"; } -/** Lower-case letters and digits only, for comparing names (`V S-Code` = `vscode`). */ +/** Look-alikes of Latin letters and digits, after NFKC (which already folds fullwidth forms). */ +const CONFUSABLE: Readonly> = { + // Cyrillic + а: "a", + в: "b", + с: "c", + е: "e", + һ: "h", + і: "i", + ј: "j", + к: "k", + ӏ: "l", + м: "m", + н: "h", + о: "o", + р: "p", + ԛ: "q", + ѕ: "s", + т: "t", + и: "u", + ѵ: "v", + ԝ: "w", + х: "x", + у: "y", + А: "a", + В: "b", + С: "c", + Е: "e", + Н: "h", + І: "i", + Ј: "j", + К: "k", + М: "m", + О: "o", + Р: "p", + Ѕ: "s", + Т: "t", + Х: "x", + У: "y", + Ү: "y", + // Greek + α: "a", + β: "b", + ε: "e", + η: "n", + ι: "i", + κ: "k", + ν: "v", + ο: "o", + ρ: "p", + τ: "t", + υ: "u", + χ: "x", + γ: "y", + Α: "a", + Β: "b", + Ε: "e", + Ζ: "z", + Η: "h", + Ι: "i", + Κ: "k", + Μ: "m", + Ν: "n", + Ο: "o", + Ρ: "p", + Τ: "t", + Υ: "y", + Χ: "x", + // Digits and letters that pass for one another + "0": "o", + "1": "l", + I: "l", + "|": "l", +}; + +/** NFKC, confusables mapped, then lower-case letters and digits only (`V S-Code` = `vscode`). */ function folded(text: string): string { let out = ""; - for (const ch of text.toLowerCase()) + for (const raw of text.normalize("NFKC")) { + const ch = (CONFUSABLE[raw] ?? raw).toLowerCase(); if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch; + } return out; } -/** The well-known client a self-chosen name contains (`Claude · from claude.ai` → `Claude`). */ -export function lookalikeOf(name: string): string | undefined { +const onHost = (host: string, hosts: readonly string[]) => + hosts.some((h) => host === h || host.endsWith(`.${h}`)); + +/** + * The well-known document client a name looks like (`Сlaude · from claude.ai` → `Claude`), unless + * the client is a metadata document on that client's own host; undefined for a server client. + */ +export function lookalikeOf(name: string, row: ClientRowIdentity): string | undefined { + const kind = clientKind(row); + if (kind === "server") return undefined; const f = folded(name); - return WELL_KNOWN.find((known) => f.includes(folded(known))); + const known = WELL_KNOWN.find((k) => f.includes(folded(k.name))); + if (!known) return undefined; + const host = kind === "document" ? (documentHost(row) ?? "").toLowerCase() : ""; + return kind === "document" && onHost(host.split(":")[0] ?? "", known.hosts) + ? undefined + : known.name; } /** The document host of a `document` client (`claude.ai`), else undefined. */ diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index 5319bddb..87cd0f50 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -5,11 +5,10 @@ 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 { cimdGuardFor } from "./cimd-guard.ts"; import type { GrantStore } from "./grants.ts"; import { betterAuthLogger, oauthEndpoints, oauthPlugins } from "./oauth.ts"; -import { countCimdClients } from "./oauth-client-rows.ts"; import { oauthBeforeHook } from "./oauth-hooks.ts"; -import { cimdLimits } from "./oauth-registration.ts"; import { API_KEY_HEADER, type AuthOptions, @@ -75,15 +74,7 @@ export function createInstance( const oauth = options.mcpOauth ? oauthPlugins(oauthEndpoints(options.baseURL).issuer, options.mcpOauth, { grantOf: async (userId, clientId) => (await internal.grants.live(userId, clientId))?.id, - cimdGuard: { - limits: cimdLimits(options.mcpOauth.registration), - clientHosts: options.mcpOauth.registration?.clientHosts, - count: () => countCimdClients(options.sql), - hit: (key, windowMs, max) => staff.hit(key, windowMs, max), - // The runtime's trusted address (#165); without one, one shared bucket as elsewhere. - address: (headers) => headers?.get(ipHeader ?? "cf-connecting-ip") ?? "unknown", - now: clock, - }, + cimdGuard: cimdGuardFor(options, staff, { now: clock, ipHeader }), }) : []; const instance = betterAuth({ diff --git a/packages/api/src/auth/oauth-client-rows.ts b/packages/api/src/auth/oauth-client-rows.ts index 58b5c8dd..6c067831 100644 --- a/packages/api/src/auth/oauth-client-rows.ts +++ b/packages/api/src/auth/oauth-client-rows.ts @@ -37,22 +37,41 @@ export async function countCimdClients(sql: SqlDriver): Promise { return Number(row?.n ?? 0); } +interface PruneKind { + readonly where: (alias: string) => string; + readonly since: "createdAt" | "updatedAt"; + readonly cutoff: string; + readonly limit: number; + /** Document hosts never deleted (`exempt`, the well-known clients and the host list). */ + readonly exempt?: readonly string[] | undefined; +} + +/** `clientId LIKE` patterns for exempt hosts: the host itself and its subdomains. */ +function exemptPatterns(hosts: readonly string[]): string[] { + return hosts.slice(0, 30).flatMap((h) => { + const host = h.toLowerCase(); + if (host.startsWith("*.")) return [`https://%.${host.slice(2)}/%`]; + return [`https://${host}/%`, `https://%.${host}/%`]; + }); +} + /** - * Deletes one kind of unused client, at most `PRUNE_BATCH`: no live connection, no connection - * made, used or ended since `cutoff`, and the client's own row older than it (`since`: when it - * was registered, or when its document was last fetched). A client with a live connection is - * never pruned, however idle: ending it is its member's decision ("Connected agents"). Every row - * the provider keeps for the client goes in the same atomic batch, each statement selecting the - * same clients (the predicate reads only `oauthClient` and `agent_grants`, which the batch - * changes last). Returns how many were deleted. + * Deletes unused clients of one kind, at most `limit`: no live connection, no connection made, + * used or ended since `cutoff`, and the client's own row older than it (`since`: when it was + * registered, or when its document was last fetched). A client with a live connection is never + * deleted, however idle: ending it is its member's decision ("Connected agents"). Every row the + * provider keeps for the client goes in the same atomic batch, each statement selecting the same + * clients (the predicate reads only `oauthClient` and `agent_grants`, which the batch changes + * last). Returns how many were deleted. */ -async function pruneKind( - sql: SqlDriver, - kind: { readonly where: (alias: string) => string; readonly since: "createdAt" | "updatedAt" }, - cutoff: string, -): Promise { - const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${PRUNE_BATCH}`; - const params = [cutoff, cutoff, cutoff, cutoff]; +async function pruneKind(sql: SqlDriver, kind: PruneKind): Promise { + const patterns = exemptPatterns(kind.exempt ?? []); + const exempt = patterns.length + ? ` AND NOT (${patterns.map(() => `c."clientId" LIKE ?`).join(" OR ")})` + : ""; + const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ?${exempt} AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${Math.max(1, Math.floor(kind.limit))}`; + const { cutoff } = kind; + const params = [cutoff, ...patterns, cutoff, cutoff, cutoff]; const code = (path: string) => `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; const results = await sql.batch([ @@ -69,8 +88,8 @@ async function pruneKind( return results.at(-1)?.changes ?? 0; } -const cutoffOf = (now: Date, days: number) => - new Date(now.getTime() - days * 86_400_000).toISOString(); +const hoursBefore = (now: Date, hours: number) => + new Date(now.getTime() - hours * 3_600_000).toISOString(); /** Dynamically registered clients unused for `unusedDays` since they registered (§3: 30). */ export function pruneDcrClients( @@ -78,19 +97,67 @@ export function pruneDcrClients( options: { readonly now: Date; readonly unusedDays?: number }, ): Promise { const days = options.unusedDays ?? DCR_LIMITS.unusedDays; - return pruneKind(sql, { where: dcrClient, since: "createdAt" }, cutoffOf(options.now, days)); + return pruneKind(sql, { + where: dcrClient, + since: "createdAt", + cutoff: hoursBefore(options.now, days * 24), + limit: PRUNE_BATCH, + }); } +/** Batches one pruning run deletes document clients in, at most (a burst drains in hours). */ +export const CIMD_PRUNE_BATCHES = 4; + /** - * Document clients nobody connected through and nobody fetched for `unusedDays` (default 1): the - * next authorization fetches the document and stores the client again. + * Document clients nobody connected through and nobody fetched for `unusedHours` (default 3), + * in up to `CIMD_PRUNE_BATCHES` batches while each comes back full: the next authorization + * fetches the document and stores the client again. */ -export function pruneCimdClients( +export async function pruneCimdClients( sql: SqlDriver, - options: { readonly now: Date; readonly unusedDays?: number }, + options: { readonly now: Date; readonly unusedHours?: number }, ): Promise { - const days = options.unusedDays ?? CIMD_LIMITS.unusedDays; - return pruneKind(sql, { where: cimdClient, since: "updatedAt" }, cutoffOf(options.now, days)); + const cutoff = hoursBefore(options.now, options.unusedHours ?? CIMD_LIMITS.unusedHours); + let total = 0; + for (let i = 0; i < CIMD_PRUNE_BATCHES; i++) { + const n = await pruneKind(sql, { + where: cimdClient, + since: "updatedAt", + cutoff, + limit: PRUNE_BATCH, + }); + total += n; + if (n < PRUNE_BATCH) break; + } + return total; +} + +/** + * Makes room for one document client (the cap): deletes the oldest one with no live connection + * and no use since `before`, never one from an exempt host. How many went (0 or 1). + */ +export function evictCimdClient( + sql: SqlDriver, + input: { readonly before: Date; readonly exempt: readonly string[] }, +): Promise { + return pruneKind(sql, { + where: cimdClient, + since: "updatedAt", + cutoff: input.before.toISOString(), + limit: 1, + exempt: input.exempt, + }); +} + +/** + * Records a failed refetch of a document client on its row: the validation time is cleared, so + * the row is never served as fresh or stale again until a fetch succeeds. + */ +export async function markCimdFetchFailed(sql: SqlDriver, clientId: string): Promise { + await sql.run( + `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" = ? AND ${cimdClient()}`, + [new Date(0).toISOString(), clientId], + ); } /** Both kinds, as the hourly job runs them; how many of each were deleted. */ diff --git a/packages/api/src/auth/oauth-registration.ts b/packages/api/src/auth/oauth-registration.ts index 50bd2d2c..f2b88243 100644 --- a/packages/api/src/auth/oauth-registration.ts +++ b/packages/api/src/auth/oauth-registration.ts @@ -35,34 +35,39 @@ export const DCR_LIMITS: DcrLimits = { }; export interface CimdLimits { - /** Metadata fetches per caller address per minute, whoever the client is. */ + /** Metadata fetches per caller address per minute, whoever the client is (past it: 429). */ readonly perAddressPerMinute: number; /** Fetches of documents not stored yet, per client origin and in all, per minute. */ readonly perOriginPerMinute: number; readonly perMinute: number; - /** Clients from metadata documents that may be stored at once (beyond it 503). */ + /** Document clients stored at once; a new one beyond it evicts an unused one, or gets 503. */ readonly maxClients: number; + /** A client fetched within this many minutes is never evicted to make room. */ + readonly evictMinutes: number; /** A stored document younger than this is used without fetching it again (minutes). */ readonly freshMinutes: number; - /** A document client with no connection and no fetch for this many days is deleted. */ - readonly unusedDays: number; + /** The oldest a stored row may be served when the fetch is declined (never after a failure). */ + readonly maxStaleHours: number; + /** A document client with no connection and no fetch for this many hours is deleted. */ + readonly unusedHours: number; } /** * Outbound metadata fetches and stored document clients, counted across every isolate in - * Cloudhal's `rate_limits` (the plugin's own budget lives in one instance, which a Worker rebuilds - * on every request). A stored client is fetched at most once per `freshMinutes`; only documents - * not stored yet spend the shared per-origin budget, so anonymous callers cannot starve the - * clients members already use. The next authorization recreates a pruned client from its - * document, so unused rows go after a day. + * Cloudhal's `rate_limits`. A stored client is fetched at most once per `freshMinutes`; only + * documents not stored yet spend the shared per-origin budget, so anonymous callers cannot + * starve the clients members already use. The next authorization recreates a pruned or evicted + * client from its document, so unused rows go after hours. */ export const CIMD_LIMITS: CimdLimits = { perAddressPerMinute: 20, perOriginPerMinute: 30, perMinute: 120, maxClients: 5_000, + evictMinutes: 10, freshMinutes: 60, - unusedDays: 1, + maxStaleHours: 24, + unusedHours: 3, }; export interface OauthRegistration { diff --git a/packages/api/src/oauth-prune-job.ts b/packages/api/src/oauth-prune-job.ts index 6c7a1aea..fca8629a 100644 --- a/packages/api/src/oauth-prune-job.ts +++ b/packages/api/src/oauth-prune-job.ts @@ -1,14 +1,14 @@ /** - * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes unused OAuth clients - * (`pruneDcrClients`, `pruneCimdClients`): dynamically registered ones nobody used for 30 days, and clients from - * metadata documents nobody connected through or fetched for a day (the next authorization - * stores them again). On the admin Worker's tick and on Node's (`cloudhal studio`, `dev`, - * `mcp`), whether DCR and CIMD are on or off. Bounded: one kind per run (alternate hours), at most - * `PRUNE_BATCH` clients, one batch. + * RFC-0044 §3 "Lifetime": the scheduler's hourly system job that deletes unused OAuth clients: + * dynamically registered ones nobody used for 30 days (`pruneDcrClients`, one batch of 50), and + * document clients nobody connected through or fetched for three hours (`pruneCimdClients`, up + * to four batches of 50 while they come back full; the next authorization stores them again). + * On the admin Worker's tick and on Node's (`cloudhal studio`, `dev`, `mcp`), whether DCR and + * CIMD are on or off. */ import { hourlyAt, type SystemJobDefinition } from "@cloudhal/schedules"; import type { SqlDriver } from "@cloudhal/storage"; -import { pruneCimdClients, pruneDcrClients } from "./auth/oauth-client-rows.ts"; +import { CIMD_PRUNE_BATCHES, pruneCimdClients, pruneDcrClients } from "./auth/oauth-client-rows.ts"; export function oauthClientsPruneJob( sql: SqlDriver, @@ -16,21 +16,19 @@ export function oauthClientsPruneJob( ): SystemJobDefinition { return { name: "oauth.clients.prune", - // One batch of six statements: one kind per run, so the job fits next to the other hourly - // jobs in a free plan's tick (DCR clients in even UTC hours, document clients in odd ones). - cost: 6, + // Batches of six statements: one for DCR, up to CIMD_PRUNE_BATCHES for documents. + cost: 6 * (1 + CIMD_PRUNE_BATCHES), // The hourly jobs' :17 watermark: due jobs run by time, then name, so this one runs after // the analytics rollup and the comments job and waits for the next minute when a free // plan's tick is full. next: hourlyAt(17), async run({ now }) { - if (now.getUTCHours() % 2 === 0) { - const n = await pruneDcrClients(sql, { now }); - if (n > 0) log(`oauth clients: ${n} unused registration(s) deleted`); - } else { - const n = await pruneCimdClients(sql, { now }); - if (n > 0) log(`oauth clients: ${n} unused document client(s) deleted`); - } + const dcr = await pruneDcrClients(sql, { now }); + const cimd = await pruneCimdClients(sql, { now }); + if (dcr + cimd > 0) + log( + `oauth clients: ${dcr} unused registration(s), ${cimd} unused document client(s) deleted`, + ); }, }; } diff --git a/packages/api/src/testing-mcp-oauth-bounds.ts b/packages/api/src/testing-mcp-oauth-bounds.ts index 4595791c..3ec0698d 100644 --- a/packages/api/src/testing-mcp-oauth-bounds.ts +++ b/packages/api/src/testing-mcp-oauth-bounds.ts @@ -13,14 +13,19 @@ * already have keep working. * 6. The consent screen's identity: names made plain and short, the kind, look-alike names. */ + +import type { ClientDiscovery } from "@better-auth/oauth-provider"; +import { APIError } from "better-auth/api"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; +import { type CimdGuard, guardedResolve } from "./auth/cimd-discovery.ts"; import { pruneCimdClients } from "./auth/oauth-client-rows.ts"; -import type { OauthRegistration } from "./auth/oauth-registration.ts"; +import { CIMD_LIMITS, type OauthRegistration } from "./auth/oauth-registration.ts"; import { DOC, document, FETCH_FAILED, + type Served, stubTransport, tryAuthorize, withCimd, @@ -42,6 +47,8 @@ import { import type { RolesBackend } from "./testing-roles.ts"; const READ = "site:read content:read"; +/** Claude Code's metadata document (RFC-0044 §3). */ +const CLAUDE_CODE = "https://claude.ai/oauth/claude-code-client-metadata"; const HOUR = 3_600_000; const DAY = 24 * HOUR; /** Cursor 3.14.27's registration as observed (forum.cursor.com/t/167608). */ @@ -169,23 +176,152 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo await ageRow(s, DOC, 2 * HOUR); expect(proceeds(await tryAuthorize(w, DOC))).toBe(true); expect(fetches()).toBe(2); - // The caller's own budget spent too: the stored client is served from its row. + // The caller's own budget spent too: that caller is told to wait (429), never served an + // outdated row it could make the server use by spending its own budget. await ageRow(s, DOC, 2 * HOUR); for (let i = 0; i < 4; i++) await anonymousAuthorize(w, `https://g${i}.example.com/c.json`, "unknown"); - const stale = await token( + const waiting = await token( { ...w, clientId: DOC }, { grant_type: "refresh_token", refresh_token: refreshed.body.refresh_token as string }, ); - expect(stale.status, JSON.stringify(stale.body)).toBe(200); + expect(waiting.status).toBe(429); expect(fetches()).toBe(2); - expect(proceeds(await tryAuthorize(w, DOC))).toBe(true); - await (await mcpClient(w.app, stale.body.access_token as string)).listTools(); - // A new document from that caller is refused while its budget is spent. + // A new document from that caller is refused too. const blocked = await tryAuthorize(w, "https://agents.example.com/new.json"); expect(blocked.status).toBe(429); }); + it("keeps a stored client refreshing on the same process after junk URLs on its origin", async () => { + // Node keeps one auth instance for the life of the process: no redeploy here. + const transport = stubTransport((url) => + url.toString() === CLAUDE_CODE + ? { body: document({ client_id: CLAUDE_CODE }) } + : { status: 404 }, + ); + const s = await setupServer(backend, withCimd(transport)); + const claude = { ...s, clientId: CLAUDE_CODE }; + const first = await connect(claude, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + // Two hours later (every cache the old code kept has expired), about 30 a minute of new + // URLs on claude.ai, from enough addresses to pass each one's budget. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 2 * HOUR); + for (let i = 0; i < 30; i++) + await anonymousAuthorize(s, `https://claude.ai/x${i}`, `198.51.100.${i % 10}`); + const renewed = await token(claude, { + grant_type: "refresh_token", + refresh_token: first.refresh ?? "", + }); + expect(renewed.status, JSON.stringify(renewed.body)).toBe(200); + expect(transport.requests.filter((u) => u === CLAUDE_CODE).length).toBe(2); + expect(proceeds(await tryAuthorize(s, CLAUDE_CODE))).toBe(true); + }); + + it("follows a changed document after the freshness window and drops a removed one", async () => { + let served: Served = { body: document() }; + const transport = stubTransport(() => served); + const s = await setupServer(backend, withCimd(transport)); + const claude = { ...s, clientId: DOC }; + const first = await connect(claude, { + cookie: s.owner.cookie, + scope: `${READ} offline_access`, + }); + // The document moves its callback: once the stored row is older than an hour, only the new + // one is accepted. + const moved = "http://127.0.0.1/moved"; + served = { body: document({ redirect_uris: [moved] }) }; + await ageRow(s, DOC, 2 * HOUR); + expect((await tryAuthorize(s, DOC, REDIRECT)).reason).toBe("invalid redirect uri"); + expect(proceeds(await tryAuthorize(s, DOC, "http://127.0.0.1:5000/moved"))).toBe(true); + // The document disappears: after the window the client is refused, at refresh too, and + // stays refused (the failure is recorded on the row), whatever budget the caller spends. + served = { status: 404 }; + await ageRow(s, DOC, 2 * HOUR); + const gone = await token(claude, { + grant_type: "refresh_token", + refresh_token: first.refresh ?? "", + }); + expect(gone.status).not.toBe(200); + const row = await s.sql.get<{ updatedAt: string }>( + `SELECT "updatedAt" FROM "oauthClient" WHERE "clientId" = ?`, + [DOC], + ); + expect(Date.parse(row?.updatedAt ?? "")).toBe(0); + for (let i = 0; i < 25; i++) + await anonymousAuthorize(s, `https://h${i}.example.com/c.json`, "unknown"); + const again = await token(claude, { + grant_type: "refresh_token", + refresh_token: first.refresh ?? "", + }); + expect(again.status).not.toBe(200); + expect((await tryAuthorize(s, DOC, "http://127.0.0.1:5000/moved")).consentScreen).toBe(false); + }); + + it("serves a stored row the plugin declines to refetch only within 24 hours, never after a failure", async () => { + const failed: string[] = []; + const guard: CimdGuard = { + limits: CIMD_LIMITS, + count: async () => 0, + evict: async () => 0, + markFailed: async (id) => { + failed.push(id); + }, + hit: async () => true, + address: () => "203.0.113.9", + now: () => new Date(), + }; + const declined = new APIError("TOO_MANY_REQUESTS", { error: "temporarily_unavailable" }); + const inner = (error: unknown) => + ({ + id: "cimd", + matches: () => true, + resolve: async () => { + throw error; + }, + }) as unknown as ClientDiscovery; + const stored = (hours: number) => + ({ clientId: DOC, updatedAt: new Date(Date.now() - hours * HOUR) }) as never; + const ctx = { headers: new Headers() } as never; + const run = (error: unknown, hours: number) => + guardedResolve(inner(error), guard, { ctx, clientId: DOC, existing: stored(hours) }); + expect(await run(declined, 2)).toMatchObject({ clientId: DOC }); + await expect(run(declined, 25)).rejects.toBe(declined); + const fetchFailed = new APIError("BAD_REQUEST", { error: "invalid_client" }); + await expect(run(fetchFailed, 2)).rejects.toBe(fetchFailed); + expect(failed).toEqual([DOC]); + }); + + it("makes room for a well-known client's first connection when the cap is full of junk", async () => { + const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) })); + const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 3 } })); + for (let i = 0; i < 3; i++) + await anonymousAuthorize(s, `https://junk${i}.example.com/c.json`, `192.0.2.${i}`); + // Full, and every junk row was fetched just now: a fourth unknown document waits. + const fourth = await anonymousAuthorize(s, "https://junk3.example.com/c.json", "192.0.2.3"); + expect(fourth.status).toBe(503); + // Claude Code's first connection here is exempt from the cap. + const first = await connect( + { ...s, clientId: CLAUDE_CODE }, + { cookie: s.owner.cookie, scope: READ }, + ); + expect(first.access).toBeTruthy(); + // Junk older than the eviction window makes way for a new document, one at a time. + await s.sql.run( + `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" LIKE 'https://junk%'`, + [new Date(Date.now() - HOUR).toISOString()], + ); + expect((await tryAuthorize(s, "https://agents.example.com/new.json")).consentScreen).toBe( + true, + ); + const left = await s.sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientId" LIKE 'https://junk%'`, + ); + expect(Number(left?.n)).toBe(2); + }); + it("registers Cursor with its real body, and refuses dangerous or unknown schemes", async () => { const s = await setupServer(backend, { registration: { dcr: true, limits: { perAddressPerHour: 100 } }, @@ -239,6 +375,14 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo expect((await to("myapp://app/callback")).status).toBe(201); expect((await to("cursor://anysphere.cursor-mcp/oauth/callback")).status).toBe(201); expect((await to("com.example.app:/oauth/callback")).status).toBe(400); + // A web client never gets a private-use redirect, whatever the provider would infer. + for (const uri of ["cursor://anysphere.cursor-mcp/oauth/callback", "com.example.app:/cb"]) { + const web = await s.call("POST", "/api/auth/oauth2/register", { + body: { ...CURSOR_BODY, application_type: "web", redirect_uris: [uri] }, + }); + expect(web.status, uri).toBe(400); + expect(web.body.error, uri).toBe("invalid_redirect_uri"); + } }); it("applies the client host list at every authorization, to clients registered before it", async () => { @@ -274,11 +418,21 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo expect(renewed.status, JSON.stringify(renewed.body)).toBe(200); }); - it("shows a client's name plain and short, its kind, and flags look-alike self-registrations", async () => { - const name = `Claude‮ edoC​ ${" ".repeat(400)}from claude.ai · sends you back to claude.ai ${"x".repeat(300)}`; - const transport = stubTransport(() => ({ body: document({ client_name: name }) })); - const s = await setupServer(backend, withCimd(transport)); - const info = async (clientId: string, redirectUri = REDIRECT) => { + it("shows a client's name plain and short, its kind, and flags look-alikes of document clients", async () => { + const name = `Claude\u202e edoC\u200b ${"\u2003".repeat(400)}from claude.ai · sends you back to claude.ai ${"x".repeat(300)}`; + const transport = stubTransport((url) => ({ + body: document({ + client_id: url.toString(), + client_name: url.toString() === CLAUDE_CODE ? "Claude Code" : name, + }), + })); + const s = await setupServer(backend, { + registration: { + ...withCimd(transport).registration, + limits: { perAddressPerHour: 100 }, + }, + }); + const consent = async (clientId: string, redirectUri = REDIRECT) => { const start = await authorize(s, { cookie: s.owner.cookie, scope: READ, @@ -290,17 +444,51 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo await s.call("GET", `/api/agent-grants/consent?request=${encodeURIComponent(request)}`, { cookie: s.owner.cookie, }) - ).body.client as Json; + ).body; }; + const info = async (clientId: string) => (await consent(clientId)).client as Json; const doc = await info(DOC); expect(doc).toMatchObject({ kind: "document", host: "agents.example.com" }); expect([...(doc.name as string)].length).toBeLessThanOrEqual(100); - expect(doc.name).not.toMatch(/[‮​ ]/); + expect(doc.name).not.toMatch(/[\u202e\u200b\u2003]/); expect(doc.name).toMatch(/^Claude edoC from claude\.ai/); - expect(doc.lookalike).toBeUndefined(); - const fake = await registerClient(s, { client_name: "Claude · Identified by claude.ai" }); - expect(await info(fake)).toMatchObject({ kind: "self-registered", lookalike: "Claude" }); + // A document named like Claude on another host is flagged; Claude Code's own is not. + expect(doc.lookalike).toBe("Claude"); + expect(await info(CLAUDE_CODE)).toMatchObject({ + kind: "document", + host: "claude.ai", + name: "Claude Code", + }); + expect((await info(CLAUDE_CODE)).lookalike).toBeUndefined(); + // Self-registered names: confusables are folded before comparing; Cursor's own name is not + // a look-alike (Cursor always registers itself). + for (const fake of [ + "Claude · Identified by claude.ai", + "\u0421laude", + "\uff23\uff4c\uff41\uff55\uff44\uff45", + "C1aude", + "CIaude", + "Chat\u0047\u0420T", + "VS \u0421ode", + ]) { + const id = await registerClient(s, { client_name: fake }); + expect((await info(id)).lookalike, fake).toBeDefined(); + } + const cursor = await registerClient(s, { client_name: "Cursor" }); + expect(await info(cursor)).toMatchObject({ kind: "self-registered", name: "Cursor" }); + expect((await info(cursor)).lookalike).toBeUndefined(); expect((await info(s.clientId)).lookalike).toBeUndefined(); // "Test Agent" + // Stacked combining marks: a few per character at most. + const marks = await registerClient(s, { client_name: `Z${"\u0301".repeat(30)}oe` }); + expect([...((await info(marks)).name as string)].length).toBe(5); + // A private-use redirect is an app on this computer, never a web host. + const app = "cursor://claude.ai/oauth/callback"; + const native = await registerClient(s, { client_name: "Cursor", redirect_uris: [app] }); + expect((await consent(native, app)).redirect).toEqual({ + host: "", + loopback: true, + app: "cursor", + }); // The connection keeps the shown name. await connect({ ...s, clientId: DOC }, { cookie: s.owner.cookie, scope: READ }); const grants = (await s.call("GET", "/api/agent-grants", { cookie: s.owner.cookie })) diff --git a/packages/api/src/testing-mcp-oauth-registration.ts b/packages/api/src/testing-mcp-oauth-registration.ts index 38ac8b95..6207b0bc 100644 --- a/packages/api/src/testing-mcp-oauth-registration.ts +++ b/packages/api/src/testing-mcp-oauth-registration.ts @@ -39,7 +39,7 @@ export const DOC = "https://agents.example.com/oauth/client.json"; /** A public address the stub resolver answers (TEST-NET-3 is not public: use a real range). */ const PUBLIC = "93.184.215.14"; -interface Served { +export interface Served { readonly status?: number; readonly headers?: Record; readonly body?: string; @@ -352,14 +352,13 @@ export function describeMcpOauthRegistration(label: string, backend: RolesBacken expect(await pruneDcrClients(s.sql, { now: new Date(Date.now() + 29 * DAY) })).toBe(0); // The scheduler's hourly job does it (Workers and Node), and says so. const lines: string[] = []; - // DCR clients go in even UTC hours (document clients in odd ones). - const later = new Date(Date.now() + 31 * DAY); - later.setUTCHours(later.getUTCHours() + (later.getUTCHours() % 2)); await oauthClientsPruneJob(s.sql, (l) => lines.push(l)).run({ - now: later, + now: new Date(Date.now() + 31 * DAY), trigger: "cron", }); - expect(lines).toEqual(["oauth clients: 2 unused registration(s) deleted"]); + expect(lines).toEqual([ + "oauth clients: 2 unused registration(s), 0 unused document client(s) deleted", + ]); expect(await clients()).toEqual([s.clientId]); const leftovers = await s.sql.get<{ n: number }>( `SELECT COUNT(*) AS n FROM "oauthRefreshToken" WHERE "clientId" = ?`, diff --git a/packages/storage/migrations/0032_oauth_client_kind_index.ts b/packages/storage/migrations/0032_oauth_client_kind_index.ts new file mode 100644 index 00000000..6e772b2b --- /dev/null +++ b/packages/storage/migrations/0032_oauth_client_kind_index.ts @@ -0,0 +1,15 @@ +import type { Migration } from "../src/migrate.ts"; + +/** + * RFC-0044 §3 (review of #178): an index on the kind of an OAuth client and when its document was + * last validated. Anonymous `/oauth2/authorize` calls count the stored metadata-document clients + * (`clientDiscoveryId = 'cimd'`, the cap), evict the oldest unused one, and the hourly job prunes + * them by `updatedAt`; without the index each of these reads the whole `oauthClient` table. A new + * migration, because 0031 is already on `main`. Plain SQL; additive. + */ +export const migration0032OauthClientKindIndex: Migration = { + name: "0032_oauth_client_kind_index", + sql: ` +CREATE INDEX "oauthClient_clientDiscoveryId_updatedAt_idx" ON "oauthClient" ("clientDiscoveryId", "updatedAt"); +`, +}; diff --git a/packages/storage/migrations/index.ts b/packages/storage/migrations/index.ts index 20aab2eb..40860008 100644 --- a/packages/storage/migrations/index.ts +++ b/packages/storage/migrations/index.ts @@ -30,6 +30,7 @@ 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"; +import { migration0032OauthClientKindIndex } from "./0032_oauth_client_kind_index.ts"; /** All Cloudhal storage migrations, in order. */ export const migrations: readonly Migration[] = [ @@ -64,4 +65,5 @@ export const migrations: readonly Migration[] = [ migration0029EntryListValues, migration0030RateLimits, migration0031Oauth, + migration0032OauthClientKindIndex, ]; diff --git a/packages/storage/test/migrate.test.ts b/packages/storage/test/migrate.test.ts index b77bc81f..dc6191cf 100644 --- a/packages/storage/test/migrate.test.ts +++ b/packages/storage/test/migrate.test.ts @@ -49,6 +49,7 @@ describe("migrate", () => { "0029_entry_list_values", "0030_rate_limits", "0031_oauth", + "0032_oauth_client_kind_index", ]); expect((await migrate(driver, migrations)).applied).toEqual([]); expect(await appliedMigrations(driver)).toEqual([ @@ -83,6 +84,7 @@ describe("migrate", () => { "0029_entry_list_values", "0030_rate_limits", "0031_oauth", + "0032_oauth_client_kind_index", ]); const tables = await driver.all<{ name: string }>( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", @@ -206,6 +208,7 @@ describe("migrate", () => { "0029_entry_list_values", "0030_rate_limits", "0031_oauth", + "0032_oauth_client_kind_index", ]); expect(await appliedMigrations(driver)).toEqual([ "0001_init", @@ -239,6 +242,7 @@ describe("migrate", () => { "0029_entry_list_values", "0030_rate_limits", "0031_oauth", + "0032_oauth_client_kind_index", ]); await h.dispose(); }); @@ -279,6 +283,7 @@ describe("migrate", () => { "0029_entry_list_values", "0030_rate_limits", "0031_oauth", + "0032_oauth_client_kind_index", ]); const ok = await driver.get("SELECT name FROM sqlite_master WHERE name = 'ok'"); expect(ok).toBeUndefined(); @@ -387,3 +392,17 @@ describe("0031_oauth: the authorization server's tables (RFC-0044 §7)", () => { await (h ? h.dispose() : driver.close()); }); }); + +describe("0032_oauth_client_kind_index (RFC-0044 §3, review of #178)", () => { + it("indexes document clients by kind and validation time, so the cap count reads the index", async () => { + const driver = await BetterSqlite3Driver.open(":memory:"); + await migrate(driver, migrations); + const plan = await driver.all<{ detail: string }>( + `EXPLAIN QUERY PLAN SELECT COUNT(*) FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`, + ); + expect(plan.map((p) => p.detail).join(" ")).toContain( + "oauthClient_clientDiscoveryId_updatedAt_idx", + ); + await driver.close(); + }); +}); From 03f6b959b49f1cb38ceedddb5a8a45ecdcb7c6aa Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:27:32 +0300 Subject: [PATCH 7/7] fix(api): evict CIMD clients by their real host; prune and look-alike hardening (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3 of #178. 1. Eviction matched exempt hosts with `clientId LIKE 'https://%.host/%'`. In SQLite `%` spans `/`, so https://junk.example/x.claude.ai/c.json passed for claude.ai and was never evicted, which brought the cap lockout back. evictCimdClient now reads the oldest grantless candidates and judges each by `new URL(clientId).hostname` with the same isExempt as admission. It deletes the pick in one atomic batch that re-checks the predicate (`clientId IN (?)`). 2. The plugin's cache size, which also bounds its per-origin and per-client fetch state, goes from 500 to 10,000. 3. oauthClientsPruneJob takes the deployment's limits (dcrUnusedDays, cimdUnusedHours). Test: 120 rows pruned in one run. 4. Test: eviction never removes a client with a live grant, nor a well-known host's row older than evictMinutes. 5. Look-alike names are folded with NFKD and their combining marks dropped (Cláude, Clȧude), with Armenian, Cherokee and Latin small capital look-alikes added to the map. A UTS #39 skeleton stays a follow-up. 6. Self-host guide: behind a reverse proxy without CLOUDHAL_BEHIND_PROXY=1, every client shares the proxy's rate-limit bucket. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/adr/0045-mcp-authorization-server.md | 2 +- docs/guides/self-host.md | 2 + packages/api/src/auth/cimd-discovery.ts | 10 ++- packages/api/src/auth/client-identity.ts | 65 +++++++++++++++- packages/api/src/auth/oauth-client-rows.ts | 55 ++++++++------ packages/api/src/oauth-prune-job.ts | 12 ++- packages/api/src/testing-mcp-oauth-bounds.ts | 78 ++++++++++++++++++++ 7 files changed, 191 insertions(+), 33 deletions(-) diff --git a/docs/adr/0045-mcp-authorization-server.md b/docs/adr/0045-mcp-authorization-server.md index cd58df2a..07164865 100644 --- a/docs/adr/0045-mcp-authorization-server.md +++ b/docs/adr/0045-mcp-authorization-server.md @@ -16,7 +16,7 @@ RFC-0044 Phase 1 made `/mcp` a resource server that names an authorization serve 6. **Registration (Phase 3a, amended 2026-11-09).** Phase 2 had none and a server-side test client (`OauthServer.createClient`); Phase 3a replaces it with the two paths of RFC-0044 §3, and `createClient` is gone (the suites register through DCR or CIMD; `clientPrivileges` now admits nobody). See "Phase 3a" below. ## Phase 3a: client registration (2026-11-09) -1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile; its own cache is off and its fetch windows are set out of reach, so the stored row is the only cache and Cloudhal's budgets the only budgets, on Workers (instance per request) and Node (instance per process) alike. A stored client validated within 60 min (its row's `updatedAt`) is used without a fetch. Every fetch spends the caller's budget (20 per address per minute, past which that caller gets 429); a document not stored yet also spends 30 per origin and 120 in all per minute, and needs room under the cap of 5,000 (the oldest grantless client not fetched for 10 min is evicted, else 503; the well-known clients' document hosts and the host list are exempt from the cap and never evicted). A failed refetch is recorded on the row (its validation time cleared) and refuses the client until a fetch succeeds; a stored row is served as it is only when the plugin itself declines to fetch, and only within 24 h of its last good fetch. Unused document clients are pruned after three hours, up to four batches of 50 an hour; an index on `("clientDiscoveryId", "updatedAt")` (migration `0032`) serves the count, the eviction and the prune. `CLOUDHAL_MCP_CIMD=off` turns it off and also ends existing document clients' connections at their next refresh (they no longer resolve), the conservative choice. +1. **CIMD** is `@better-auth/cimd` 1.7.18's discovery with the MCP 2026-07-28 profile, wrapped by Cloudhal (`cimd-discovery.ts`) and fetched through `cimdFetch` over the media import's `safeFetch` (RFC-0004 §5.7): `nodeTransport()` on Node (every address public, connection pinned), `workersTransport()` on the admin Worker (DoH checks); https on 443, DNS names only, no redirects, 5 KB, 5 s, never the deployment's own host or a site's domain. The plugin checks `client_id` = URL, the JSON media type and the profile; its own cache is off and its fetch windows and state capacity (10,000) are set out of reach, so the stored row is the only cache and Cloudhal's budgets the only budgets, on Workers (instance per request) and Node (instance per process) alike. A stored client validated within 60 min (its row's `updatedAt`) is used without a fetch. Every fetch spends the caller's budget (20 per address per minute, past which that caller gets 429); a document not stored yet also spends 30 per origin and 120 in all per minute, and needs room under the cap of 5,000 (the oldest grantless client not fetched for 10 min is evicted, else 503; the well-known clients' document hosts and the host list are exempt from the cap and never evicted, judged on the URL's host name in code, never by `LIKE` over the URL, whose `%` spans `/`). A failed refetch is recorded on the row (its validation time cleared) and refuses the client until a fetch succeeds; a stored row is served as it is only when the plugin itself declines to fetch, and only within 24 h of its last good fetch. Unused document clients are pruned after three hours, up to four batches of 50 an hour; an index on `("clientDiscoveryId", "updatedAt")` (migration `0032`) serves the count, the eviction and the prune. `CLOUDHAL_MCP_CIMD=off` turns it off and also ends existing document clients' connections at their next refresh (they no longer resolve), the conservative choice. 2. **DCR** is Cloudhal's route `POST /api/auth/oauth2/register` (`dcr.ts`), mounted before Better Auth's catch-all: rate limits in `rate_limits` (10 per address per hour, 300 per day, every attempt counted), a 5 KB body, a cap of 5,000 clients (503), public clients only (an absent method becomes `none`; no secret is ever minted), `client_name` 1–100 characters, `grant_types` reduced to `authorization_code`/`refresh_token`, and every other field dropped before the provider's `registerOAuthClient` runs server-side with empty headers. The provider's own endpoint answers 404 to HTTP callers (the `before` hook and the fence, which opens exactly `REGISTER_PATH` only while DCR is on). Clients with no live connection and no connection activity for 30 days are deleted by the hourly `oauth.clients.prune` system job on both schedulers (document clients after a day), with every provider row of theirs in one batch per kind. `CLOUDHAL_MCP_DCR=off` removes the route and `registration_endpoint`, and `/oauth2/authorize` refuses the clients DCR stored earlier; connections they already have keep working until revoked or pruned. 3. **Redirect URIs** (`redirect-policy.ts`): https on a non-loopback host; http on exactly `localhost`/`127.0.0.1`/`[::1]` with any port (the provider's RFC 8252 matching); or a native app's private-use scheme (`scheme:/…`, matched exactly) that is reverse-domain or a known app's (`cursor`, `vscode`, `CLOUDHAL_MCP_REDIRECT_SCHEMES`), never `javascript`/`data`/`file`/`vbscript`/`about`/`blob` and friends or an `http` look-alike; at most 10, one refused entry refusing the registration. The rules and the host list run at registration (DCR) and at every authorization (`validateRedirectUri`). 4. **`CLOUDHAL_MCP_CLIENT_HOSTS`** (opt-in, Decision 9): the hosts a metadata document may come from (stored clients included) and every client may redirect to (`localhost` admits loopback; private-use redirects then need a known scheme). A value that does not parse turns registration off. diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md index 83c4e7ff..8dcc9bd2 100644 --- a/docs/guides/self-host.md +++ b/docs/guides/self-host.md @@ -60,6 +60,8 @@ location / { } ``` +> **Warning:** behind a reverse proxy, always set `CLOUDHAL_BEHIND_PROXY=1` (the `--behind-proxy` flag of `cloudhal studio`). Without it, every request seems to come from the proxy's own address, so every client shares one rate-limit bucket. Remote MCP clients are hit hardest: 20 junk authorization requests a minute from anyone then block the token refreshes of every connected agent whose client document is due for its hourly refetch ([hardening](cloudflare-hardening.md#remote-mcp-clients-oauth)). + Serve `/data/dist` from the volume for the site host. It is the stable path: a real folder with the 0.6.3 image, and from 0.6.4 on a symlink to the newest build, which the server must follow (nginx and Caddy do by default). A build replaces the symlink atomically, so the server never sees it missing — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside and the symlink takes its place. A mount point of your own at `/data/dist` cannot be moved: `site-build` refuses it, so serve the volume's `/data/dist` instead. ## Environment diff --git a/packages/api/src/auth/cimd-discovery.ts b/packages/api/src/auth/cimd-discovery.ts index cba18f69..f987f26f 100644 --- a/packages/api/src/auth/cimd-discovery.ts +++ b/packages/api/src/auth/cimd-discovery.ts @@ -57,7 +57,7 @@ export interface CimdGuard { * Deletes the oldest document client with no live connection and no use since `before`, * whose host is not in `exempt`; how many went (0 or 1). */ - readonly evict: (input: { before: Date; exempt: readonly string[] }) => Promise; + readonly evict: (input: { before: Date; isExempt: (host: string) => boolean }) => Promise; /** Records a failed refetch on the row (its validation time cleared). */ readonly markFailed: (clientId: string) => Promise; /** One hit of a fixed-window counter (`StaffStore.hit`); false once `max` is passed. */ @@ -83,7 +83,7 @@ export function exemptHosts(guard: Pick): string[] { return [...WELL_KNOWN_DOCUMENT_HOSTS, ...(guard.clientHosts ?? [])]; } -function isExempt(host: string, exempt: readonly string[]): boolean { +export function isExempt(host: string, exempt: readonly string[]): boolean { return exempt.some( (p) => hostMatches(host, p) || (!p.startsWith("*.") && hostMatches(host, `*.${p}`)), ); @@ -102,7 +102,7 @@ async function admit(guard: CimdGuard, host: string): Promise { const exempt = exemptHosts(guard); if (isExempt(host, exempt) || (await guard.count()) < guard.limits.maxClients) return; const before = new Date(guard.now().getTime() - guard.limits.evictMinutes * 60_000); - if ((await guard.evict({ before, exempt })) > 0) return; + if ((await guard.evict({ before, isExempt: (h) => isExempt(h, exempt) })) > 0) return; throw unavailable( "SERVICE_UNAVAILABLE", "this server stores no more clients right now; try again later", @@ -167,7 +167,9 @@ export function guardedCimd( metadataProfile: "mcp-2026-07-28", // The stored row is the cache (step 2): the plugin's own would outlive a changed document. metadataRevalidationInterval: 0, - maxCacheEntries: 500, + // Also the size of the plugin's per-origin and per-client fetch state, which refuses at + // capacity: out of reach too (entries hold at most a 5 KB document and its validators). + maxCacheEntries: 10_000, // Cloudhal's budgets in `rate_limits` are the only ones (steps 3 and 4): on Node the // plugin's windows would last the life of the process and could be drained anonymously. metadataFetchPolicy: { diff --git a/packages/api/src/auth/client-identity.ts b/packages/api/src/auth/client-identity.ts index 023370b5..27bb3131 100644 --- a/packages/api/src/auth/client-identity.ts +++ b/packages/api/src/auth/client-identity.ts @@ -10,8 +10,9 @@ * * A name that looks like one of the well-known document clients' (`lookalike`: Claude, Claude * Code, ChatGPT, Codex, VS Code) is flagged unless the client is that client's document on its - * real host. Names are compared after NFKC and a small confusable map (Cyrillic and Greek - * look-alikes of Latin letters, `0`/`O`, `1`/`l`/`I`); a full UTS #39 skeleton is a follow-up. + * real host. Names are compared after NFKD without combining marks and a small confusable map + * (Cyrillic, Greek, Armenian and Cherokee look-alikes of Latin letters, Latin small capitals, + * `0`/`O`, `1`/`l`/`I`); a full UTS #39 skeleton is a follow-up (#144). * * Linear only (AGENTS.md rule 13): one pass over the code points; the only pattern tests one * character at a time. @@ -184,6 +185,58 @@ const CONFUSABLE: Readonly> = { Τ: "t", Υ: "y", Χ: "x", + // Armenian + ս: "u", + օ: "o", + ո: "n", + հ: "h", + ց: "g", + զ: "q", + // Latin small capitals and other look-alikes outside a-z + ᴀ: "a", + ʙ: "b", + ᴄ: "c", + ᴅ: "d", + ᴇ: "e", + ɢ: "g", + ʜ: "h", + ɪ: "i", + ᴊ: "j", + ᴋ: "k", + ʟ: "l", + ᴍ: "m", + ɴ: "n", + ᴏ: "o", + ᴘ: "p", + ʀ: "r", + ꜱ: "s", + ᴛ: "t", + ᴜ: "u", + ᴠ: "v", + ᴡ: "w", + ʏ: "y", + ᴢ: "z", + ɑ: "a", + ı: "i", + ȷ: "j", + // Cherokee + Ꭺ: "a", + Ᏼ: "b", + Ꮯ: "c", + Ꭰ: "d", + Ꭼ: "e", + Ꮐ: "g", + Ꮋ: "h", + Ꭻ: "j", + Ꮶ: "k", + Ꮮ: "l", + Ꮇ: "m", + Ꮲ: "p", + Ꮪ: "s", + Ꭲ: "t", + Ꮩ: "v", + Ꮃ: "w", + Ꮓ: "z", // Digits and letters that pass for one another "0": "o", "1": "l", @@ -191,10 +244,14 @@ const CONFUSABLE: Readonly> = { "|": "l", }; -/** NFKC, confusables mapped, then lower-case letters and digits only (`V S-Code` = `vscode`). */ +/** + * NFKD with its combining marks dropped (`Cláude`, `Clȧude` → `Claude`; fullwidth forms fold + * too), confusables mapped, then lower-case letters and digits only (`V S-Code` = `vscode`). + */ function folded(text: string): string { let out = ""; - for (const raw of text.normalize("NFKC")) { + for (const raw of text.normalize("NFKD")) { + if (MARK.test(raw)) continue; const ch = (CONFUSABLE[raw] ?? raw).toLowerCase(); if ((ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9")) out += ch; } diff --git a/packages/api/src/auth/oauth-client-rows.ts b/packages/api/src/auth/oauth-client-rows.ts index 6c067831..ab3f3a57 100644 --- a/packages/api/src/auth/oauth-client-rows.ts +++ b/packages/api/src/auth/oauth-client-rows.ts @@ -42,17 +42,8 @@ interface PruneKind { readonly since: "createdAt" | "updatedAt"; readonly cutoff: string; readonly limit: number; - /** Document hosts never deleted (`exempt`, the well-known clients and the host list). */ - readonly exempt?: readonly string[] | undefined; -} - -/** `clientId LIKE` patterns for exempt hosts: the host itself and its subdomains. */ -function exemptPatterns(hosts: readonly string[]): string[] { - return hosts.slice(0, 30).flatMap((h) => { - const host = h.toLowerCase(); - if (host.startsWith("*.")) return [`https://%.${host.slice(2)}/%`]; - return [`https://${host}/%`, `https://%.${host}/%`]; - }); + /** Only these clients (eviction's pick), each still checked against the predicate. */ + readonly ids?: readonly string[] | undefined; } /** @@ -65,13 +56,11 @@ function exemptPatterns(hosts: readonly string[]): string[] { * last). Returns how many were deleted. */ async function pruneKind(sql: SqlDriver, kind: PruneKind): Promise { - const patterns = exemptPatterns(kind.exempt ?? []); - const exempt = patterns.length - ? ` AND NOT (${patterns.map(() => `c."clientId" LIKE ?`).join(" OR ")})` - : ""; - const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ?${exempt} AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${Math.max(1, Math.floor(kind.limit))}`; + const ids = kind.ids ?? []; + const only = ids.length ? ` AND c."clientId" IN (${ids.map(() => "?").join(", ")})` : ""; + const prunable = `SELECT c."clientId" FROM "oauthClient" c WHERE ${kind.where("c.")} AND c."${kind.since}" < ?${only} AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."${kind.since}", c."clientId" LIMIT ${Math.max(1, Math.floor(kind.limit))}`; const { cutoff } = kind; - const params = [cutoff, ...patterns, cutoff, cutoff, cutoff]; + const params = [cutoff, ...ids, cutoff, cutoff, cutoff]; const code = (path: string) => `CASE WHEN json_valid("value") THEN json_extract("value", '${path}') END`; const results = await sql.batch([ @@ -132,23 +121,45 @@ export async function pruneCimdClients( return total; } +/** How many of the oldest unused document clients eviction looks at for a non-exempt one. */ +export const EVICT_CANDIDATES = 50; + /** * Makes room for one document client (the cap): deletes the oldest one with no live connection - * and no use since `before`, never one from an exempt host. How many went (0 or 1). + * and no use since `before` whose **host** is not exempt. The host is read from the URL, never + * matched with `LIKE` over the whole `client_id` (`%` also spans `/`, so a path could pass for + * an exempt host). The candidates are read first; the pick is deleted in one atomic batch that + * checks the predicate again. How many went (0 or 1). */ -export function evictCimdClient( +export async function evictCimdClient( sql: SqlDriver, - input: { readonly before: Date; readonly exempt: readonly string[] }, + input: { readonly before: Date; readonly isExempt: (host: string) => boolean }, ): Promise { + const cutoff = input.before.toISOString(); + const candidates = await sql.all<{ clientId: string }>( + `SELECT c."clientId" FROM "oauthClient" c WHERE ${cimdClient("c.")} AND c."updatedAt" < ? AND NOT EXISTS (SELECT 1 FROM agent_grants g WHERE g.client_id = c."clientId" AND (g.revoked_at IS NULL OR g.created_at >= ? OR g.last_used_at >= ? OR g.revoked_at >= ?)) ORDER BY c."updatedAt", c."clientId" LIMIT ${EVICT_CANDIDATES}`, + [cutoff, cutoff, cutoff, cutoff], + ); + const victim = candidates.find((r) => !input.isExempt(hostnameOf(r.clientId))); + if (!victim) return 0; return pruneKind(sql, { where: cimdClient, since: "updatedAt", - cutoff: input.before.toISOString(), + cutoff, limit: 1, - exempt: input.exempt, + ids: [victim.clientId], }); } +/** A client id's host name, lower case; "" when it is not a URL (never exempt). */ +function hostnameOf(clientId: string): string { + try { + return new URL(clientId).hostname.toLowerCase(); + } catch { + return ""; + } +} + /** * Records a failed refetch of a document client on its row: the validation time is cleared, so * the row is never served as fresh or stale again until a fetch succeeds. diff --git a/packages/api/src/oauth-prune-job.ts b/packages/api/src/oauth-prune-job.ts index fca8629a..818a83d4 100644 --- a/packages/api/src/oauth-prune-job.ts +++ b/packages/api/src/oauth-prune-job.ts @@ -13,6 +13,8 @@ import { CIMD_PRUNE_BATCHES, pruneCimdClients, pruneDcrClients } from "./auth/oa export function oauthClientsPruneJob( sql: SqlDriver, log: (line: string) => void, + /** The deployment's limits (`DcrLimits.unusedDays`, `CimdLimits.unusedHours`); default theirs. */ + limits: { readonly dcrUnusedDays?: number; readonly cimdUnusedHours?: number } = {}, ): SystemJobDefinition { return { name: "oauth.clients.prune", @@ -23,8 +25,14 @@ export function oauthClientsPruneJob( // plan's tick is full. next: hourlyAt(17), async run({ now }) { - const dcr = await pruneDcrClients(sql, { now }); - const cimd = await pruneCimdClients(sql, { now }); + const dcr = await pruneDcrClients(sql, { + now, + ...(limits.dcrUnusedDays !== undefined ? { unusedDays: limits.dcrUnusedDays } : {}), + }); + const cimd = await pruneCimdClients(sql, { + now, + ...(limits.cimdUnusedHours !== undefined ? { unusedHours: limits.cimdUnusedHours } : {}), + }); if (dcr + cimd > 0) log( `oauth clients: ${dcr} unused registration(s), ${cimd} unused document client(s) deleted`, diff --git a/packages/api/src/testing-mcp-oauth-bounds.ts b/packages/api/src/testing-mcp-oauth-bounds.ts index 3ec0698d..1ec886fd 100644 --- a/packages/api/src/testing-mcp-oauth-bounds.ts +++ b/packages/api/src/testing-mcp-oauth-bounds.ts @@ -21,6 +21,7 @@ import { clearAccessTokenKeyCache } from "./auth/access-token.ts"; import { type CimdGuard, guardedResolve } from "./auth/cimd-discovery.ts"; import { pruneCimdClients } from "./auth/oauth-client-rows.ts"; import { CIMD_LIMITS, type OauthRegistration } from "./auth/oauth-registration.ts"; +import { oauthClientsPruneJob } from "./oauth-prune-job.ts"; import { DOC, document, @@ -294,6 +295,78 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo expect(failed).toEqual([DOC]); }); + it("evicts by the client's real host: a path that names a well-known host is still junk", async () => { + const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) })); + const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 3 } })); + // `%` in SQL LIKE spans `/`: these would pass for claude.ai with a pattern over the URL. + for (let i = 0; i < 3; i++) + await anonymousAuthorize( + s, + `https://junk${i}.example.com/x.claude.ai/c.json`, + `192.0.2.${i}`, + ); + await s.sql.run( + `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientId" LIKE 'https://junk%'`, + [new Date(Date.now() - HOUR).toISOString()], + ); + // A third-party client (not well-known) still gets its first connection: one junk row goes. + expect( + (await tryAuthorize(s, "https://agents.example.com/inspector.json")).consentScreen, + ).toBe(true); + const left = await s.sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientId" LIKE 'https://junk%'`, + ); + expect(Number(left?.n)).toBe(2); + }); + + it("never evicts a client with a live connection or a well-known host's, however old", async () => { + const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) })); + const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 2 } })); + await connect({ ...s, clientId: DOC }, { cookie: s.owner.cookie, scope: READ }); // live grant + await anonymousAuthorize(s, CLAUDE_CODE, "192.0.2.50"); // stored, no grant, exempt host + await s.sql.run( + `UPDATE "oauthClient" SET "updatedAt" = ? WHERE "clientDiscoveryId" = 'cimd'`, + [new Date(Date.now() - DAY).toISOString()], + ); + const refused = await anonymousAuthorize(s, "https://junk.example.com/c.json", "192.0.2.51"); + expect(refused.status).toBe(503); + const kept = await s.sql.all<{ clientId: string }>( + `SELECT "clientId" FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd' ORDER BY "clientId"`, + ); + expect(kept.map((r) => r.clientId)).toEqual([DOC, CLAUDE_CODE].sort()); + }); + + it("prunes more than one batch of unused document clients in a run, with the operator's hours", async () => { + const s = await setupServer(backend); + const old = new Date(Date.now() - 2 * HOUR).toISOString(); + for (let i = 0; i < 120; i++) + await s.sql.run( + `INSERT INTO "oauthClient" ("id", "clientId", "clientDiscoveryId", "redirectUris", "createdAt", "updatedAt") VALUES (?, ?, 'cimd', '[]', ?, ?)`, + [`junk${i}`, `https://junk${i}.example.com/c.json`, old, old], + ); + const count = async () => + Number( + ( + await s.sql.get<{ n: number }>( + `SELECT COUNT(*) AS n FROM "oauthClient" WHERE "clientDiscoveryId" = 'cimd'`, + ) + )?.n, + ); + const lines: string[] = []; + const run = (limits: { cimdUnusedHours?: number }) => + oauthClientsPruneJob(s.sql, (l) => lines.push(l), limits).run({ + now: new Date(), + trigger: "cron", + }); + await run({}); // two hours old: under the default three + expect(await count()).toBe(120); + await run({ cimdUnusedHours: 1 }); + expect(await count()).toBe(0); + expect(lines).toEqual([ + "oauth clients: 0 unused registration(s), 120 unused document client(s) deleted", + ]); + }); + it("makes room for a well-known client's first connection when the cap is full of junk", async () => { const transport = stubTransport((url) => ({ body: document({ client_id: url.toString() }) })); const s = await setupServer(backend, withCimd(transport, { cimdLimits: { maxClients: 3 } })); @@ -470,6 +543,11 @@ export function describeMcpOauthBounds(label: string, backend: RolesBackend): vo "CIaude", "Chat\u0047\u0420T", "VS \u0421ode", + "Cl\u00e1ude", + "Cla\u0307ude", + "\u1d04\u029f\u1d00\u1d1c\u1d05\u1d07", + "\u13dflaude", + "Cl\u0430\u057dde", ]) { const id = await registerClient(s, { client_name: fake }); expect((await info(id)).lookalike, fake).toBeDefined();