Skip to content

Commit fd4fb02

Browse files
authored
Return expired health verdicts for rejected OAuth refreshes and fix DCR reconnect (#1316)
* Add e2e repro for MCP OAuth health-check 500 and reconnect modal close * Fix MCP OAuth health and reconnect flows
1 parent 4a84b0d commit fd4fb02

8 files changed

Lines changed: 507 additions & 41 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Selfhost repros for two MCP OAuth bugs seen with a DCR connection whose
2+
// refresh token is rejected by the provider as `invalid_grant`.
3+
import { randomBytes } from "node:crypto";
4+
5+
import { Effect } from "effect";
6+
import { expect } from "@effect/vitest";
7+
import type { HttpApiClient } from "effect/unstable/httpapi";
8+
import type { Page } from "playwright";
9+
import { composePluginApi } from "@executor-js/api/server";
10+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
11+
import {
12+
AuthTemplateSlug,
13+
ConnectionName,
14+
IntegrationSlug,
15+
OAuthClientSlug,
16+
} from "@executor-js/sdk/shared";
17+
import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing";
18+
19+
import { scenario } from "../src/scenario";
20+
import { Api, Browser, Target } from "../src/services";
21+
22+
const api = composePluginApi([mcpHttpPlugin()] as const);
23+
type Client = HttpApiClient.ForApi<typeof api>;
24+
25+
const name = ConnectionName.make("main");
26+
const template = AuthTemplateSlug.make("oauth2");
27+
28+
const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`;
29+
30+
const healthPath = (slug: IntegrationSlug): string =>
31+
`/api/connections/org/${String(slug)}/${String(name)}/health`;
32+
33+
const oauthReconnectRequest = (url: string): boolean =>
34+
url.includes("/api/oauth/probe") ||
35+
url.includes("/api/oauth/start") ||
36+
url.includes("/api/oauth/clients/register-dynamic");
37+
38+
const connectionsSection = (page: Page) =>
39+
page.locator("section").filter({
40+
has: page.getByRole("heading", { level: 3, name: "Connections" }),
41+
});
42+
43+
const requiredRedirect = (response: Response, from: string): string => {
44+
const location = response.headers.get("location");
45+
if (!location) {
46+
throw new Error(`Expected redirect from ${from}, got HTTP ${response.status}`);
47+
}
48+
return new URL(location, from).toString();
49+
};
50+
51+
const completeAuthorization = (authorizationUrl: string) =>
52+
Effect.promise(async () => {
53+
const login = await fetch(authorizationUrl, { redirect: "manual" });
54+
const loginUrl = requiredRedirect(login, authorizationUrl);
55+
const credentials = Buffer.from("alice:password").toString("base64");
56+
const callback = await fetch(loginUrl, {
57+
method: "POST",
58+
headers: { authorization: `Basic ${credentials}` },
59+
redirect: "manual",
60+
});
61+
const callbackUrl = requiredRedirect(callback, loginUrl);
62+
const parsed = new URL(callbackUrl);
63+
const code = parsed.searchParams.get("code");
64+
if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`);
65+
return { code };
66+
});
67+
68+
const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
69+
Effect.gen(function* () {
70+
const oauth = yield* serveOAuthTestServer({
71+
scopes: ["channels:history", "users:read"],
72+
supportRefresh: false,
73+
tokenExpiresInSeconds: 0,
74+
invalidRefreshTokenDescription: "Grant not found",
75+
});
76+
const slug = IntegrationSlug.make(freshSlug(prefix));
77+
const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`));
78+
79+
yield* client.mcp.addServer({
80+
payload: {
81+
transport: "remote",
82+
name: `OAuth repro ${String(slug)}`,
83+
endpoint: oauth.mcpResourceUrl,
84+
slug: String(slug),
85+
authenticationTemplate: [{ kind: "oauth2" }],
86+
},
87+
});
88+
yield* Effect.addFinalizer(() =>
89+
client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore),
90+
);
91+
92+
const probe = yield* client.oauth.probe({ payload: { url: oauth.mcpResourceUrl } });
93+
if (!probe.registrationEndpoint) {
94+
return yield* Effect.die("OAuth probe did not discover a DCR registration endpoint");
95+
}
96+
97+
const registered = yield* client.oauth.registerDynamic({
98+
payload: {
99+
owner: "org",
100+
slug: clientSlug,
101+
issuer: probe.issuer ?? null,
102+
registrationEndpoint: probe.registrationEndpoint,
103+
authorizationUrl: probe.authorizationUrl,
104+
tokenUrl: probe.tokenUrl,
105+
resource: probe.resource ?? oauth.mcpResourceUrl,
106+
scopes: probe.scopesSupported ?? [],
107+
tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
108+
clientName: "Executor e2e MCP OAuth repro",
109+
originIntegration: slug,
110+
},
111+
});
112+
yield* Effect.addFinalizer(() =>
113+
client.oauth
114+
.removeClient({ params: { slug: registered.client }, payload: { owner: "org" } })
115+
.pipe(Effect.ignore),
116+
);
117+
118+
const started = yield* client.oauth.start({
119+
payload: {
120+
owner: "org",
121+
client: registered.client,
122+
clientOwner: "org",
123+
name,
124+
integration: slug,
125+
template,
126+
},
127+
});
128+
expect(started.status, "DCR MCP OAuth starts an authorization-code redirect").toBe("redirect");
129+
if (started.status !== "redirect") return yield* Effect.die("OAuth start did not redirect");
130+
131+
const callback = yield* completeAuthorization(started.authorizationUrl);
132+
yield* client.oauth.complete({ payload: { state: started.state, code: callback.code } });
133+
yield* Effect.addFinalizer(() =>
134+
client.connections
135+
.remove({ params: { owner: "org", integration: slug, name } })
136+
.pipe(Effect.ignore),
137+
);
138+
yield* oauth.clearRequests;
139+
140+
return { oauth, slug };
141+
});
142+
143+
const logTokenRequests = (label: string, oauth: OAuthTestServerShape) =>
144+
Effect.gen(function* () {
145+
const requests = yield* oauth.requests;
146+
const refresh = requests
147+
.filter((request) => request.path === "/token" && request.body.includes("refresh_token"))
148+
.map((request) => `${request.method} ${request.path} ${request.body}`);
149+
console.info(`[BUG repro] ${label}: refresh token requests: ${refresh.join(" | ") || "none"}`);
150+
});
151+
152+
scenario(
153+
"MCP OAuth · invalid_grant refresh during health check returns expired instead of 500",
154+
{
155+
timeout: 180_000,
156+
},
157+
Effect.scoped(
158+
Effect.gen(function* () {
159+
const target = yield* Target;
160+
const browser = yield* Browser;
161+
const { client: makeApiClient } = yield* Api;
162+
const identity = yield* target.newIdentity();
163+
const client = yield* makeApiClient(api, identity);
164+
const { oauth, slug } = yield* seedExpiredDcrMcpOAuthConnection(client, "mcp-hc-invalid");
165+
166+
const apiResult = yield* client.connections.checkHealth({
167+
params: { owner: "org", integration: slug, name },
168+
query: {},
169+
});
170+
expect(apiResult.status, "typed checkHealth classifies the dead grant").toBe("expired");
171+
expect(apiResult.detail, "the provider rejection detail is surfaced").toContain(
172+
"Grant not found",
173+
);
174+
const reread = yield* client.connections.get({
175+
params: { owner: "org", integration: slug, name },
176+
});
177+
expect(reread.lastHealth?.status, "the expired health verdict persisted").toBe("expired");
178+
expect(reread.lastHealth?.detail, "the persisted detail is useful").toContain(
179+
"Grant not found",
180+
);
181+
yield* logTokenRequests("typed checkHealth", oauth);
182+
yield* oauth.clearRequests;
183+
184+
yield* browser.session(identity, async ({ page, step }) => {
185+
const connections = connectionsSection(page);
186+
const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
187+
188+
await step("Open the MCP integration with its expired OAuth connection", async () => {
189+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
190+
await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
191+
});
192+
193+
await step(
194+
"Check now should render Expired without the generic failure toast",
195+
async () => {
196+
const responsePromise = page.waitForResponse(
197+
(response) =>
198+
response.url().includes(healthPath(slug)) && response.request().method() === "POST",
199+
{ timeout: 30_000 },
200+
);
201+
await menuTrigger.click();
202+
await page.getByRole("menuitem", { name: "Check now" }).click();
203+
const response = await responsePromise;
204+
const body = await response.text();
205+
console.info(`[BUG repro] UI health response: ${response.status()} ${body}`);
206+
207+
expect(
208+
response.status(),
209+
`health check should return HTTP 200 with status expired; body: ${body}`,
210+
).toBe(200);
211+
const json = JSON.parse(body) as { readonly status?: string };
212+
expect(json.status, "unrefreshable OAuth grants are an expired credential").toBe(
213+
"expired",
214+
);
215+
await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 });
216+
await page.getByText("Health check failed", { exact: true }).waitFor({
217+
state: "hidden",
218+
timeout: 5_000,
219+
});
220+
},
221+
);
222+
});
223+
}),
224+
),
225+
);
226+
227+
scenario(
228+
"MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start",
229+
{
230+
timeout: 180_000,
231+
},
232+
Effect.scoped(
233+
Effect.gen(function* () {
234+
const target = yield* Target;
235+
const browser = yield* Browser;
236+
const { client: makeApiClient } = yield* Api;
237+
const identity = yield* target.newIdentity();
238+
const client = yield* makeApiClient(api, identity);
239+
const { slug } = yield* seedExpiredDcrMcpOAuthConnection(client, "mcp-dcr-reconnect");
240+
241+
yield* browser.session(identity, async ({ page, step }) => {
242+
const connections = connectionsSection(page);
243+
const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
244+
const dialog = page.getByRole("dialog");
245+
const oauthRequests: string[] = [];
246+
247+
page.on("request", (request) => {
248+
if (oauthReconnectRequest(request.url())) oauthRequests.push(request.url());
249+
});
250+
251+
await step("Open the MCP integration with its DCR OAuth connection", async () => {
252+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
253+
await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
254+
});
255+
256+
await step("Reconnect should keep a dialog visible and reach OAuth", async () => {
257+
const oauthRequest = page
258+
.waitForRequest((request) => oauthReconnectRequest(request.url()), { timeout: 30_000 })
259+
.then((request) => request.url());
260+
261+
await menuTrigger.click();
262+
await page.getByRole("menuitem", { name: "Reconnect" }).click();
263+
264+
await dialog.waitFor({ state: "visible", timeout: 30_000 });
265+
const reachedOAuth = await oauthRequest;
266+
await page.waitForTimeout(2_000);
267+
await dialog.waitFor({ state: "visible", timeout: 1_000 });
268+
console.info(
269+
`[MCP OAuth repro] reconnect dialog stayed open; OAuth requests: ${
270+
oauthRequests.join(", ") || reachedOAuth
271+
}`,
272+
);
273+
expect(reachedOAuth, "Reconnect should issue an OAuth request").toBeTruthy();
274+
});
275+
});
276+
}),
277+
),
278+
);

e2e/src/scenario.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export interface ScenarioOptions {
5858
* body never runs. Use ONLY for a scenario blocked on a tracked, out-of-scope
5959
* issue; state the reason here so the skip is self-documenting in the source. */
6060
readonly skip?: string;
61+
/** When set, the scenario is registered as a Vitest expected failure. The body
62+
* still runs and records artifacts; Vitest keeps CI green only while the
63+
* tracked bug still reproduces. */
64+
readonly expectedFailure?: string;
6165
}
6266

6367
type AllServices =
@@ -129,8 +133,9 @@ export const scenario = (
129133
const dir = join(RUNS_DIR, target.name, slugify(name));
130134
const context = contextFor(target, dir);
131135
const testFile = captureTestFile();
136+
const register = options.expectedFailure ? it.live.fails : it.live;
132137

133-
it.live(
138+
register(
134139
name,
135140
(testCtx) =>
136141
Effect.gen(function* () {

packages/core/sdk/src/client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,19 @@ export interface IntegrationAccountHandoff {
122122
readonly template?: string;
123123
/** Non-secret connection label to prefill. */
124124
readonly label?: string;
125+
/** Existing display identity to preserve when reconnecting a saved row. */
126+
readonly identityLabel?: string;
125127
/** Present when the agent handed off a CONFIDENTIAL OAuth-app registration
126-
* (via `oauth.clients.createHandoff`): the accounts UI opens the
128+
* (via `oauth.clients.createHandoff`) or when a saved OAuth connection is
129+
* reconnecting through its stored app. Registration opens the
127130
* Register-OAuth-app form pre-filled with these NON-secret fields, and the
128-
* human types the client secret directly into the browser. */
131+
* human types the client secret directly into the browser. Reconnect starts
132+
* OAuth with the existing public client. */
129133
readonly oauthClient?: {
134+
readonly action?: "register" | "reconnect";
130135
/** Preselected client slug; when set the form's slug is fixed. */
131136
readonly slug?: string;
137+
readonly owner?: "org" | "user";
132138
readonly grant?: string;
133139
readonly clientId?: string;
134140
readonly authorizationUrl?: string;

0 commit comments

Comments
 (0)