Skip to content

Commit 4f3f528

Browse files
committed
fix(oauth): request offline_access for Vercel MCP
1 parent b22d536 commit 4f3f528

4 files changed

Lines changed: 100 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.

‎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: 38 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,35 @@ 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+
408440
it.effect(
409441
"(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata",
410442
() =>

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,24 @@ interface LoadedOAuthClient {
551551
readonly tokenRequestFormat?: "form" | "json";
552552
}
553553

554+
/** Provider lifecycle scopes that are required to keep an authorization-code
555+
* connection renewable but are omitted from the protected resource's API
556+
* scope list. Vercel's MCP resource advertises only `openid`, while its
557+
* authorization server issues a refresh token only when `offline_access` is
558+
* requested. Keep the exception bound to Vercel's exact official authorize
559+
* endpoint so an unrelated OAuth server never receives a broader request. */
560+
const additionalAuthorizationLifecycleScopes = (client: {
561+
readonly authorizationUrl: string;
562+
}): readonly string[] => {
563+
if (!URL.canParse(client.authorizationUrl)) return [];
564+
const authorization = new URL(client.authorizationUrl);
565+
return authorization.protocol === "https:" &&
566+
authorization.hostname === "vercel.com" &&
567+
authorization.pathname === "/oauth/authorize"
568+
? ["offline_access"]
569+
: [];
570+
};
571+
554572
/** Where an OAuth app's client secret is stored in the default writable
555573
* provider — derived solely from the app's (owner, slug) identity. */
556574
const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string =>
@@ -1411,6 +1429,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
14111429
});
14121430
}
14131431
const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported);
1432+
const registrationScopes = dedupeScopes([
1433+
...input.scopes,
1434+
...additionalAuthorizationLifecycleScopes(input),
1435+
]);
14141436
const information = yield* registerDynamicClientDcr(
14151437
{
14161438
registrationEndpoint: input.registrationEndpoint,
@@ -1421,7 +1443,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
14211443
response_types: ["code"],
14221444
token_endpoint_auth_method: authMethod,
14231445
application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web",
1424-
scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined,
1446+
scope: registrationScopes.length > 0 ? registrationScopes.join(" ") : undefined,
14251447
},
14261448
},
14271449
{ httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy },
@@ -1974,6 +1996,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
19741996
const completeAuthorizationScopes = dedupeScopes([
19751997
...authorizationRequestedScopes,
19761998
...(firstParty?.additionalAuthorizationScopes ?? []),
1999+
...additionalAuthorizationLifecycleScopes(client),
19772000
]);
19782001

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

0 commit comments

Comments
 (0)