diff --git a/.changeset/vercel-mcp-refresh-token.md b/.changeset/vercel-mcp-refresh-token.md new file mode 100644 index 000000000..dbcf4e5d2 --- /dev/null +++ b/.changeset/vercel-mcp-refresh-token.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Keep Vercel MCP connections renewable by requesting the provider's `offline_access` lifecycle scope during registration and authorization. diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad7634..584b65ee0 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 }); diff --git a/e2e/selfhost/vercel-oauth-lifecycle.test.ts b/e2e/selfhost/vercel-oauth-lifecycle.test.ts new file mode 100644 index 000000000..cc02af906 --- /dev/null +++ b/e2e/selfhost/vercel-oauth-lifecycle.test.ts @@ -0,0 +1,146 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { connectEmulator } from "@executor-js/emulate"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Vercel OAuth · lifecycle scopes survive registration and a complete MCP connection", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const session = mcp.session(identity); + // Authenticate the host MCP transport before minting upstream credentials. + // The hosted emulator currently loses its OAuth token map on eviction. + const ready = yield* session.call("execute", { code: "return true;" }); + expect(ready.ok).toBe(true); + const base = yield* createEmulatorInstance("mcp", "vercel-lifecycle"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: base, service: "mcp" }), + ); + yield* Effect.promise(() => + emulator.seed({ users: [{ login: "lifecycle-user" }], scopes: ["openid"] }), + ); + const slug = IntegrationSlug.make(`lifecycle-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + yield* Effect.addFinalizer(() => + client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore), + ); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Lifecycle MCP", + endpoint: `${base}/mcp`, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + const registered = yield* client.oauth.registerDynamic({ + payload: { + owner: "org", + slug: app, + issuer: base, + registrationEndpoint: `${base}/register`, + authorizationUrl: "https://vercel.com/oauth/authorize", + tokenUrl: `${base}/token`, + resource: `${base}/mcp`, + scopes: ["openid"], + tokenEndpointAuthMethodsSupported: ["none"], + originIntegration: slug, + }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: registered.client }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: registered.client, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("oauth2"), + }, + }); + if (started.status !== "redirect") + return yield* Effect.die("Expected authorization redirect"); + yield* Effect.addFinalizer(() => + client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore), + ); + const authorize = new URL(started.authorizationUrl); + expect(authorize.origin + authorize.pathname).toBe("https://vercel.com/oauth/authorize"); + expect(authorize.searchParams.get("scope")?.split(" ")).toEqual(["openid", "offline_access"]); + // Route only the provider transport to the published emulator. The product + // produced all OAuth parameters; DCR, PKCE, consent, callback and MCP are real. + // This fixture does not claim to prove Vercel's refresh-token issuance policy. + const consentUrl = `${base}/authorize${authorize.search}`; + yield* browser.session(identity, async ({ page, step }) => { + await step("Review the provider consent request", async () => { + await page.goto(consentUrl); + await page.getByText("Authorize MCP client", { exact: true }).waitFor(); + }); + await step("Approve the requested access and complete the callback", async () => { + const form = Object.fromEntries(authorize.searchParams); + const approved = await page.request.post(`${base}/authorize/approve`, { + form: { ...form, login: "lifecycle-user" }, + maxRedirects: 0, + }); + expect(approved.status()).toBe(302); + const callback = approved.headers().location; + expect(callback).toBeDefined(); + await page.goto(callback!); + await page + .getByText(/connected|complete|success/i) + .first() + .waitFor({ timeout: 30_000 }); + }); + }); + const tools = yield* client.tools.list({ query: { integration: slug } }); + const tool = tools.find((entry) => entry.name === "get_me"); + expect(tool).toBeDefined(); + if (!tool) return yield* Effect.die("Connected MCP has no get_me tool"); + let result = yield* session.call("execute", { + code: `const path = ${JSON.stringify(String(tool.address))}.split(".").slice(1); let call = tools; for (const part of path) call = call[part]; return await call({});`, + }); + for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1) + result = yield* session.approvePaused(result.text); + expect(result.ok).toBe(true); + expect(result.text).toContain("lifecycle-user"); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + const registration = ledger.find( + (entry) => entry.method === "POST" && entry.path.endsWith("/register"), + ); + expect(registration?.request.body).toMatchObject({ scope: "openid offline_access" }); + expect( + ledger.some( + (entry) => + entry.method === "POST" && + entry.path.endsWith("/token") && + entry.response.status === 200, + ), + ).toBe(true); + }), + ), +); diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 6a5a28140..bc13050de 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -373,6 +373,39 @@ describe("oauth.registerDynamicClient", () => { ), ); + it.effect("registers Vercel clients with offline_access for refresh tokens", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + scopes: ["openid", "offline_access"], + }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: "https://vercel.com", + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: "https://vercel.com/oauth/authorize", + tokenUrl: server.tokenEndpoint, + resource: "https://mcp.vercel.com/", + scopes: ["openid"], + tokenEndpointAuthMethodsSupported: ["none"], + clientName: "Executor", + redirectUri: FLOW_REDIRECT_URI, + originIntegration: INTEG, + }); + + const requests = yield* server.requests; + const registration = requests.find( + (request) => request.path === "/register" && request.method === "POST", + ); + expect(registration?.body).toContain('"scope":"openid offline_access"'); + }), + ), + ); + it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 6e7aa4166..78f6d6df2 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -159,11 +159,14 @@ const serveMetadataServer = (config: { * given server as its resource, returning the executor ready to `oauth.start`. * The shared setup for the discovery cases below; case (h) inlines its own (no * `resource`) because the absent resource IS the case under test. */ -const setupMcpScopeClient = (server: { - readonly authorizationEndpoint: string; - readonly tokenEndpoint: string; - readonly mcpResourceUrl: string; -}) => +const setupMcpScopeClient = ( + server: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly mcpResourceUrl: string; + }, + options: { readonly authorizationEndpoint?: string } = {}, +) => Effect.gen(function* () { const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); @@ -171,7 +174,7 @@ const setupMcpScopeClient = (server: { yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, - authorizationUrl: server.authorizationEndpoint, + authorizationUrl: options.authorizationEndpoint ?? server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", @@ -405,6 +408,57 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("requests Vercel offline_access so authorization-code connections can refresh", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ + prm: { scopesSupported: ["openid"] }, + }); + const executor = yield* setupMcpScopeClient(server, { + authorizationEndpoint: "https://vercel.com/oauth/authorize", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([ + "openid", + "offline_access", + ]); + }), + ), + ); + + it.effect("does not add Vercel lifecycle scopes on a different port", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ prm: { scopesSupported: ["openid"] } }); + const executor = yield* setupMcpScopeClient(server, { + authorizationEndpoint: "https://vercel.com:8443/oauth/authorize", + }); + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["openid"]); + }), + ), + ); + it.effect( "(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index dd443144b..51d22c8f4 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -551,6 +551,23 @@ interface LoadedOAuthClient { readonly tokenRequestFormat?: "form" | "json"; } +/** Provider lifecycle scopes that are required to keep an authorization-code + * connection renewable but are omitted from the protected resource's API + * scope list. Vercel's MCP resource advertises only `openid`, while its + * authorization server issues a refresh token only when `offline_access` is + * requested. Keep the exception bound to Vercel's exact official authorize + * endpoint so an unrelated OAuth server never receives a broader request. */ +const additionalAuthorizationLifecycleScopes = (client: { + readonly authorizationUrl: string; +}): readonly string[] => { + if (!URL.canParse(client.authorizationUrl)) return []; + const authorization = new URL(client.authorizationUrl); + return authorization.origin === "https://vercel.com" && + authorization.pathname === "/oauth/authorize" + ? ["offline_access"] + : []; +}; + /** Where an OAuth app's client secret is stored in the default writable * provider — derived solely from the app's (owner, slug) identity. */ const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string => @@ -1411,6 +1428,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported); + const registrationScopes = dedupeScopes([ + ...input.scopes, + ...additionalAuthorizationLifecycleScopes(input), + ]); const information = yield* registerDynamicClientDcr( { registrationEndpoint: input.registrationEndpoint, @@ -1421,7 +1442,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { response_types: ["code"], token_endpoint_auth_method: authMethod, application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web", - scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined, + scope: registrationScopes.length > 0 ? registrationScopes.join(" ") : undefined, }, }, { httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy }, @@ -1974,6 +1995,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const completeAuthorizationScopes = dedupeScopes([ ...authorizationRequestedScopes, ...(firstParty?.additionalAuthorizationScopes ?? []), + ...additionalAuthorizationLifecycleScopes(client), ]); // authorization_code: persist a session + build the authorize URL.