Skip to content

Commit 929b233

Browse files
fix(oauth): request offline_access for Vercel MCP (#1951)
* fix(oauth): request offline_access for Vercel MCP * Test queue timeout with a controlled clock * Verify Vercel lifecycle scopes through an OAuth connection --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent b961092 commit 929b233

5 files changed

Lines changed: 267 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/sdk": patch
3+
---
4+
5+
Keep Vercel MCP connections renewable by requesting the provider's `offline_access` lifecycle scope during registration and authorization.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
import { expect } from "@effect/vitest";
4+
import { connectEmulator } from "@executor-js/emulate";
5+
import { Effect } from "effect";
6+
import { composePluginApi } from "@executor-js/api/server";
7+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
8+
import {
9+
AuthTemplateSlug,
10+
ConnectionName,
11+
IntegrationSlug,
12+
OAuthClientSlug,
13+
} from "@executor-js/sdk/shared";
14+
15+
import { createEmulatorInstance } from "../src/emulator-instance";
16+
import { scenario } from "../src/scenario";
17+
import { Api, Browser, Mcp, Target } from "../src/services";
18+
19+
const api = composePluginApi([mcpHttpPlugin()] as const);
20+
21+
scenario(
22+
"Vercel OAuth · lifecycle scopes survive registration and a complete MCP connection",
23+
{ timeout: 180_000 },
24+
Effect.scoped(
25+
Effect.gen(function* () {
26+
const target = yield* Target;
27+
const browser = yield* Browser;
28+
const mcp = yield* Mcp;
29+
const { client: makeClient } = yield* Api;
30+
const identity = yield* target.newIdentity();
31+
const client = yield* makeClient(api, identity);
32+
const session = mcp.session(identity);
33+
// Authenticate the host MCP transport before minting upstream credentials.
34+
// The hosted emulator currently loses its OAuth token map on eviction.
35+
const ready = yield* session.call("execute", { code: "return true;" });
36+
expect(ready.ok).toBe(true);
37+
const base = yield* createEmulatorInstance("mcp", "vercel-lifecycle");
38+
const emulator = yield* Effect.promise(() =>
39+
connectEmulator({ baseUrl: base, service: "mcp" }),
40+
);
41+
yield* Effect.promise(() =>
42+
emulator.seed({ users: [{ login: "lifecycle-user" }], scopes: ["openid"] }),
43+
);
44+
const slug = IntegrationSlug.make(`lifecycle-${randomBytes(4).toString("hex")}`);
45+
const app = OAuthClientSlug.make(`${slug}-app`);
46+
yield* Effect.addFinalizer(() =>
47+
client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore),
48+
);
49+
yield* client.mcp.addServer({
50+
payload: {
51+
transport: "remote",
52+
name: "Lifecycle MCP",
53+
endpoint: `${base}/mcp`,
54+
slug,
55+
authenticationTemplate: [{ kind: "oauth2" }],
56+
},
57+
});
58+
const registered = yield* client.oauth.registerDynamic({
59+
payload: {
60+
owner: "org",
61+
slug: app,
62+
issuer: base,
63+
registrationEndpoint: `${base}/register`,
64+
authorizationUrl: "https://vercel.com/oauth/authorize",
65+
tokenUrl: `${base}/token`,
66+
resource: `${base}/mcp`,
67+
scopes: ["openid"],
68+
tokenEndpointAuthMethodsSupported: ["none"],
69+
originIntegration: slug,
70+
},
71+
});
72+
yield* Effect.addFinalizer(() =>
73+
client.oauth
74+
.removeClient({ params: { slug: registered.client }, payload: { owner: "org" } })
75+
.pipe(Effect.ignore),
76+
);
77+
const started = yield* client.oauth.start({
78+
payload: {
79+
owner: "org",
80+
client: registered.client,
81+
clientOwner: "org",
82+
name: ConnectionName.make("main"),
83+
integration: slug,
84+
template: AuthTemplateSlug.make("oauth2"),
85+
},
86+
});
87+
if (started.status !== "redirect")
88+
return yield* Effect.die("Expected authorization redirect");
89+
yield* Effect.addFinalizer(() =>
90+
client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore),
91+
);
92+
const authorize = new URL(started.authorizationUrl);
93+
expect(authorize.origin + authorize.pathname).toBe("https://vercel.com/oauth/authorize");
94+
expect(authorize.searchParams.get("scope")?.split(" ")).toEqual(["openid", "offline_access"]);
95+
// Route only the provider transport to the published emulator. The product
96+
// produced all OAuth parameters; DCR, PKCE, consent, callback and MCP are real.
97+
// This fixture does not claim to prove Vercel's refresh-token issuance policy.
98+
const consentUrl = `${base}/authorize${authorize.search}`;
99+
yield* browser.session(identity, async ({ page, step }) => {
100+
await step("Review the provider consent request", async () => {
101+
await page.goto(consentUrl);
102+
await page.getByText("Authorize MCP client", { exact: true }).waitFor();
103+
});
104+
await step("Approve the requested access and complete the callback", async () => {
105+
const form = Object.fromEntries(authorize.searchParams);
106+
const approved = await page.request.post(`${base}/authorize/approve`, {
107+
form: { ...form, login: "lifecycle-user" },
108+
maxRedirects: 0,
109+
});
110+
expect(approved.status()).toBe(302);
111+
const callback = approved.headers().location;
112+
expect(callback).toBeDefined();
113+
await page.goto(callback!);
114+
await page
115+
.getByText(/connected|complete|success/i)
116+
.first()
117+
.waitFor({ timeout: 30_000 });
118+
});
119+
});
120+
const tools = yield* client.tools.list({ query: { integration: slug } });
121+
const tool = tools.find((entry) => entry.name === "get_me");
122+
expect(tool).toBeDefined();
123+
if (!tool) return yield* Effect.die("Connected MCP has no get_me tool");
124+
let result = yield* session.call("execute", {
125+
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({});`,
126+
});
127+
for (let attempts = 0; result.text.includes("executionId:") && attempts < 10; attempts += 1)
128+
result = yield* session.approvePaused(result.text);
129+
expect(result.ok).toBe(true);
130+
expect(result.text).toContain("lifecycle-user");
131+
const ledger = yield* Effect.promise(() => emulator.ledger.list());
132+
const registration = ledger.find(
133+
(entry) => entry.method === "POST" && entry.path.endsWith("/register"),
134+
);
135+
expect(registration?.request.body).toMatchObject({ scope: "openid offline_access" });
136+
expect(
137+
ledger.some(
138+
(entry) =>
139+
entry.method === "POST" &&
140+
entry.path.endsWith("/token") &&
141+
entry.response.status === 200,
142+
),
143+
).toBe(true);
144+
}),
145+
),
146+
);

‎packages/core/sdk/src/oauth-register-dynamic.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,39 @@ describe("oauth.registerDynamicClient", () => {
373373
),
374374
);
375375

376+
it.effect("registers Vercel clients with offline_access for refresh tokens", () =>
377+
Effect.scoped(
378+
Effect.gen(function* () {
379+
const server = yield* serveOAuthTestServer({
380+
scopes: ["openid", "offline_access"],
381+
});
382+
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
383+
yield* executor.acme.seed();
384+
385+
yield* executor.oauth.registerDynamicClient({
386+
owner: "org",
387+
slug: CLIENT,
388+
issuer: "https://vercel.com",
389+
registrationEndpoint: server.registrationEndpoint,
390+
authorizationUrl: "https://vercel.com/oauth/authorize",
391+
tokenUrl: server.tokenEndpoint,
392+
resource: "https://mcp.vercel.com/",
393+
scopes: ["openid"],
394+
tokenEndpointAuthMethodsSupported: ["none"],
395+
clientName: "Executor",
396+
redirectUri: FLOW_REDIRECT_URI,
397+
originIntegration: INTEG,
398+
});
399+
400+
const requests = yield* server.requests;
401+
const registration = requests.find(
402+
(request) => request.path === "/register" && request.method === "POST",
403+
);
404+
expect(registration?.body).toContain('"scope":"openid offline_access"');
405+
}),
406+
),
407+
);
408+
376409
it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () =>
377410
Effect.scoped(
378411
Effect.gen(function* () {

‎packages/core/sdk/src/oauth-scope-union.test.ts‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,22 @@ const serveMetadataServer = (config: {
159159
* given server as its resource, returning the executor ready to `oauth.start`.
160160
* The shared setup for the discovery cases below; case (h) inlines its own (no
161161
* `resource`) because the absent resource IS the case under test. */
162-
const setupMcpScopeClient = (server: {
163-
readonly authorizationEndpoint: string;
164-
readonly tokenEndpoint: string;
165-
readonly mcpResourceUrl: string;
166-
}) =>
162+
const setupMcpScopeClient = (
163+
server: {
164+
readonly authorizationEndpoint: string;
165+
readonly tokenEndpoint: string;
166+
readonly mcpResourceUrl: string;
167+
},
168+
options: { readonly authorizationEndpoint?: string } = {},
169+
) =>
167170
Effect.gen(function* () {
168171
const plugins = [memoryCredentialsPlugin(), makeMcpScopePlugin({ scopes: null })] as const;
169172
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
170173
yield* executor.mcp.seed();
171174
yield* executor.oauth.createClient({
172175
owner: "org",
173176
slug: CLIENT,
174-
authorizationUrl: server.authorizationEndpoint,
177+
authorizationUrl: options.authorizationEndpoint ?? server.authorizationEndpoint,
175178
tokenUrl: server.tokenEndpoint,
176179
grant: "authorization_code",
177180
clientId: "test-client",
@@ -405,6 +408,57 @@ describe("oauth.start integration-driven scopes", () => {
405408
),
406409
);
407410

411+
it.effect("requests Vercel offline_access so authorization-code connections can refresh", () =>
412+
Effect.scoped(
413+
Effect.gen(function* () {
414+
const server = yield* serveMetadataServer({
415+
prm: { scopesSupported: ["openid"] },
416+
});
417+
const executor = yield* setupMcpScopeClient(server, {
418+
authorizationEndpoint: "https://vercel.com/oauth/authorize",
419+
});
420+
421+
const started = yield* executor.oauth.start({
422+
owner: "org",
423+
client: CLIENT,
424+
clientOwner: "org",
425+
name: ConnectionName.make("main"),
426+
integration: INTEG,
427+
template: TEMPLATE,
428+
});
429+
expect(started.status).toBe("redirect");
430+
if (started.status !== "redirect") return;
431+
432+
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([
433+
"openid",
434+
"offline_access",
435+
]);
436+
}),
437+
),
438+
);
439+
440+
it.effect("does not add Vercel lifecycle scopes on a different port", () =>
441+
Effect.scoped(
442+
Effect.gen(function* () {
443+
const server = yield* serveMetadataServer({ prm: { scopesSupported: ["openid"] } });
444+
const executor = yield* setupMcpScopeClient(server, {
445+
authorizationEndpoint: "https://vercel.com:8443/oauth/authorize",
446+
});
447+
const started = yield* executor.oauth.start({
448+
owner: "org",
449+
client: CLIENT,
450+
clientOwner: "org",
451+
name: ConnectionName.make("main"),
452+
integration: INTEG,
453+
template: TEMPLATE,
454+
});
455+
expect(started.status).toBe("redirect");
456+
if (started.status !== "redirect") return;
457+
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["openid"]);
458+
}),
459+
),
460+
);
461+
408462
it.effect(
409463
"(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata",
410464
() =>

‎packages/core/sdk/src/oauth-service.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,23 @@ interface LoadedOAuthClient {
556556
readonly tokenRequestFormat?: "form" | "json";
557557
}
558558

559+
/** Provider lifecycle scopes that are required to keep an authorization-code
560+
* connection renewable but are omitted from the protected resource's API
561+
* scope list. Vercel's MCP resource advertises only `openid`, while its
562+
* authorization server issues a refresh token only when `offline_access` is
563+
* requested. Keep the exception bound to Vercel's exact official authorize
564+
* endpoint so an unrelated OAuth server never receives a broader request. */
565+
const additionalAuthorizationLifecycleScopes = (client: {
566+
readonly authorizationUrl: string;
567+
}): readonly string[] => {
568+
if (!URL.canParse(client.authorizationUrl)) return [];
569+
const authorization = new URL(client.authorizationUrl);
570+
return authorization.origin === "https://vercel.com" &&
571+
authorization.pathname === "/oauth/authorize"
572+
? ["offline_access"]
573+
: [];
574+
};
575+
559576
/** Where an OAuth app's client secret is stored in the default writable
560577
* provider — derived solely from the app's (owner, slug) identity. */
561578
const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string =>
@@ -1416,6 +1433,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
14161433
});
14171434
}
14181435
const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported);
1436+
const registrationScopes = dedupeScopes([
1437+
...input.scopes,
1438+
...additionalAuthorizationLifecycleScopes(input),
1439+
]);
14191440
const information = yield* registerDynamicClientDcr(
14201441
{
14211442
registrationEndpoint: input.registrationEndpoint,
@@ -1426,7 +1447,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
14261447
response_types: ["code"],
14271448
token_endpoint_auth_method: authMethod,
14281449
application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web",
1429-
scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined,
1450+
scope: registrationScopes.length > 0 ? registrationScopes.join(" ") : undefined,
14301451
},
14311452
},
14321453
{ httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy },
@@ -1988,6 +2009,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
19882009
const completeAuthorizationScopes = dedupeScopes([
19892010
...authorizationRequestedScopes,
19902011
...(firstParty?.additionalAuthorizationScopes ?? []),
2012+
...additionalAuthorizationLifecycleScopes(client),
19912013
]);
19922014

19932015
// authorization_code: persist a session + build the authorize URL.

0 commit comments

Comments
 (0)