From 627524840cd764b55a567dc79a94f5f22e191ca2 Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Fri, 11 Sep 2026 14:59:49 +0800 Subject: [PATCH 1/3] Advertise refresh-token grant in OAuth client metadata --- .changeset/cimd-refresh-token.md | 10 +++++++ e2e/selfhost/mcp-oauth-cimd-connect.test.ts | 29 ++++++++++++++++--- .../src/server/oauth-client-metadata.test.ts | 2 ++ .../api/src/server/oauth-client-metadata.ts | 10 +++++-- 4 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 .changeset/cimd-refresh-token.md diff --git a/.changeset/cimd-refresh-token.md b/.changeset/cimd-refresh-token.md new file mode 100644 index 0000000000..22408ee8d1 --- /dev/null +++ b/.changeset/cimd-refresh-token.md @@ -0,0 +1,10 @@ +--- +"@executor-js/api": patch +--- + +Advertise refresh-token support in OAuth client ID metadata documents. + +OAuth providers may reject the `offline_access` scope when the client's +metadata declares only the authorization-code grant. Hosted and local client +metadata now declare both `authorization_code` and `refresh_token`, matching +Executor's dynamic client registration behavior. diff --git a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts index 94f9d5fa1c..95b76d55f0 100644 --- a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts +++ b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts @@ -30,7 +30,7 @@ scenario( const oauth = yield* OAuthTestServer; const server = yield* serveMcpServerWithOAuth( () => makeGreetingMcpServer({ name: "cimd-connect-mcp" }), - { path: "/mcp" }, + { path: "/mcp", scopes: ["read", "offline_access"] }, ); const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); @@ -69,12 +69,26 @@ scenario( authorize, "the popup reached the discovered authorization endpoint", ).toBeDefined(); - const clientId = authorize?.query["client_id"]; - createdClientId = clientId; + expect( + (authorize?.query["scope"] ?? "").split(" "), + "authorization requests the resource's offline access scope", + ).toContain("offline_access"); + const clientId = authorize?.query["client_id"] ?? ""; + createdClientId = clientId || undefined; expect( clientId, "authorization uses Executor's metadata document as client_id", ).toMatch(/^https?:\/\/[^/]+\/api\/oauth\/client-id-metadata\/.+\.json$/); + const metadataResponse = await page.request.get(clientId); + expect(metadataResponse.status(), "the client metadata document is reachable").toBe( + 200, + ); + expect( + await metadataResponse.json(), + "the client declares the grant required by offline_access", + ).toMatchObject({ + grant_types: ["authorization_code", "refresh_token"], + }); await popup.close(); }); }); @@ -105,5 +119,12 @@ scenario( ), ); }), - ).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))), + ).pipe( + Effect.provide( + OAuthTestServer.layer({ + clientIdMetadataDocumentSupported: true, + scopes: ["read", "offline_access"], + }), + ), + ), ); diff --git a/packages/core/api/src/server/oauth-client-metadata.test.ts b/packages/core/api/src/server/oauth-client-metadata.test.ts index fd730dabe7..384c0e3187 100644 --- a/packages/core/api/src/server/oauth-client-metadata.test.ts +++ b/packages/core/api/src/server/oauth-client-metadata.test.ts @@ -19,6 +19,7 @@ describe("OAuth client ID metadata document", () => { "http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json", ); expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.token_endpoint_auth_method).toBe("none"); expect(metadata.application_type).toBe("web"); }); @@ -63,6 +64,7 @@ describe("OAuth client ID metadata document", () => { "http://localhost/api/oauth/callback", "http://[::1]/api/oauth/callback", ]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.application_type).toBe("native"); }); diff --git a/packages/core/api/src/server/oauth-client-metadata.ts b/packages/core/api/src/server/oauth-client-metadata.ts index c597745d59..d2e30d729c 100644 --- a/packages/core/api/src/server/oauth-client-metadata.ts +++ b/packages/core/api/src/server/oauth-client-metadata.ts @@ -9,6 +9,10 @@ export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX = export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const; export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const; +// Keep CIMD aligned with DCR: providers may reject `offline_access` unless the +// client declares that it can use the refresh-token grant. +const OAUTH_CLIENT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const; + type MetadataTarget = | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET @@ -19,7 +23,7 @@ interface OAuthClientIdMetadataDocument { readonly client_name: string; readonly client_uri: string; readonly redirect_uris: readonly string[]; - readonly grant_types: readonly ["authorization_code"]; + readonly grant_types: typeof OAUTH_CLIENT_GRANT_TYPES; readonly response_types: readonly ["code"]; readonly token_endpoint_auth_method: "none"; readonly application_type: "web" | "native"; @@ -129,7 +133,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor Local", client_uri: url.origin, redirect_uris: localLoopbackRedirectUris(mountPrefix), - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "native", @@ -150,7 +154,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor", client_uri: url.origin, redirect_uris: [redirectUri.toString()], - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "web", From 39753e651efb2f8739c60ca08007b8e1d84dda8a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:04:24 -0700 Subject: [PATCH 2/3] Test OAuth metadata through connection and tool use --- e2e/selfhost/mcp-oauth-cimd-connect.test.ts | 41 ++++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts index 95b76d55f0..dfb6827121 100644 --- a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts +++ b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts @@ -20,7 +20,7 @@ import { visit } from "../src/surfaces/browser"; const api = composePluginApi([mcpHttpPlugin()] as const); scenario( - "MCP OAuth · advertised CIMD starts authorization without dynamic registration", + "MCP OAuth · CIMD advertises refresh support and completes connection without dynamic registration", { timeout: 180_000 }, Effect.scoped( Effect.gen(function* () { @@ -89,11 +89,48 @@ scenario( ).toMatchObject({ grant_types: ["authorization_code", "refresh_token"], }); - await popup.close(); + expect(authorize).toBeDefined(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: authorization must exist before completing the flow + if (authorize === undefined) throw new Error("Missing authorization request"); + const completed = await Effect.runPromise( + oauth.completeAuthorizationCodeFlow({ authorizationUrl: authorize.url }), + ); + await popup.goto(completed.callbackUrl); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden" }); + await popup.close().catch(() => undefined); }); }); + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect(connections, "the OAuth callback saved the connection").toHaveLength(1); + const tools = yield* client.tools.list({ query: { integration: slug } }); + expect( + tools.some((tool) => tool.name === "simple_echo"), + "authenticated discovery finds the upstream tool", + ).toBe(true); + + const invoked = yield* client.executions.execute({ + payload: { + code: `return await ${tools[0]?.address}({});`, + autoApprove: true, + }, + }); + expect(invoked.status).toBe("completed"); + expect(invoked.text, "the connected tool runs through authenticated MCP").toContain( + "mcp-ok", + ); + const requests = yield* oauth.requests; + expect( + requests.some( + (request) => + request.path === "/token" && + new URLSearchParams(request.body).get("grant_type") === "authorization_code", + ), + "the callback exchanged the code using the advertised client", + ).toBe(true); expect( requests.filter((request) => request.method === "POST" && request.path === "/register"), "CIMD wins when the server also advertises DCR", From 3d47b87ece0b9c3e7d95c1b19e6d91d706fb7bbf Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:06 -0700 Subject: [PATCH 3/3] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true });