Skip to content

Commit a2bc74b

Browse files
committed
Verify Vercel lifecycle scopes through an OAuth connection
1 parent d893a11 commit a2bc74b

3 files changed

Lines changed: 169 additions & 2 deletions

File tree

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-scope-union.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,28 @@ describe("oauth.start integration-driven scopes", () => {
437437
),
438438
);
439439

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+
440462
it.effect(
441463
"(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata",
442464
() =>

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,8 +562,7 @@ const additionalAuthorizationLifecycleScopes = (client: {
562562
}): readonly string[] => {
563563
if (!URL.canParse(client.authorizationUrl)) return [];
564564
const authorization = new URL(client.authorizationUrl);
565-
return authorization.protocol === "https:" &&
566-
authorization.hostname === "vercel.com" &&
565+
return authorization.origin === "https://vercel.com" &&
567566
authorization.pathname === "/oauth/authorize"
568567
? ["offline_access"]
569568
: [];

0 commit comments

Comments
 (0)